Compare commits
No commits in common. "4d5e2e4194144be6580eb2e020f9098627cebdaf" and "376e79536cd0c16ad1ad84a137def4a82b63b8aa" have entirely different histories.
4d5e2e4194
...
376e79536c
|
|
@ -44,7 +44,6 @@ pkg_check_modules(JXL REQUIRED IMPORTED_TARGET libjxl libjxl_threads)
|
||||||
add_library(fgc_core STATIC
|
add_library(fgc_core STATIC
|
||||||
src/core/Config.cpp
|
src/core/Config.cpp
|
||||||
src/core/Paths.cpp
|
src/core/Paths.cpp
|
||||||
src/core/SingleInstance.cpp
|
|
||||||
src/core/Logger.cpp
|
src/core/Logger.cpp
|
||||||
src/core/Geometry.cpp
|
src/core/Geometry.cpp
|
||||||
src/core/ScanGrid.cpp
|
src/core/ScanGrid.cpp
|
||||||
|
|
@ -66,7 +65,6 @@ add_library(fgc_core STATIC
|
||||||
src/core/GatedCameraSource.cpp
|
src/core/GatedCameraSource.cpp
|
||||||
src/sensors/Sht41Protocol.cpp
|
src/sensors/Sht41Protocol.cpp
|
||||||
src/ui/UiSnapshot.cpp
|
src/ui/UiSnapshot.cpp
|
||||||
src/ui/Scroll.cpp
|
|
||||||
src/ui/HeadlessUi.cpp
|
src/ui/HeadlessUi.cpp
|
||||||
ini.c
|
ini.c
|
||||||
)
|
)
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -47,33 +47,6 @@ Dependencies and the Vimba X SDK setup are in [docs/build-and-setup.md](docs/bui
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
On a tower the program must outlive the ssh session that started it, so **`scripts/fgc`** runs it
|
|
||||||
detached inside a tmux session:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/fgc start --start # start detached; returns immediately
|
|
||||||
scripts/fgc attach # watch the dashboard — Ctrl-b d leaves it running
|
|
||||||
scripts/fgc status # running? since when? which PID?
|
|
||||||
scripts/fgc stop # SIGTERM -> clean camera/motor teardown
|
|
||||||
scripts/fgc peek # snapshot of the pane without attaching
|
|
||||||
```
|
|
||||||
|
|
||||||
Disconnecting from ssh while attached — or closing the laptop — is invisible to the program: tmux owns
|
|
||||||
the pty. **When attached, the dashboard's `q` key exits the program; `Ctrl-b d` is what detaches.**
|
|
||||||
|
|
||||||
From a dev box, `./deploy.sh --run` builds on the device and starts it this way; `--attach`, `--status`
|
|
||||||
and `--stop` drive it afterwards without re-syncing.
|
|
||||||
|
|
||||||
**Only one instance may run at a time.** The binary takes an exclusive `flock` on
|
|
||||||
`/run/user/<uid>/fire_gimbal_control.lock` (or `/tmp/fire_gimbal_control-<uid>.lock`) at startup and
|
|
||||||
exits with status 2 and `another instance is already running (PID N)` if it is taken — two instances
|
|
||||||
would fight over the serial port, the camera and the exposure store. The check is in the binary, so a
|
|
||||||
bare `./build/fire_gimbal_control` is refused too. Nothing needs cleaning up after a crash or a power
|
|
||||||
cut: the kernel drops the lock when the process dies. `--no-lock` bypasses it for a second mock-only
|
|
||||||
instance during development.
|
|
||||||
|
|
||||||
For development, or for a foreground run you intend to babysit, use `run.sh` directly:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Real deployment (needs cameras, motor MCU on the configured serial port, MQTT broker):
|
# Real deployment (needs cameras, motor MCU on the configured serial port, MQTT broker):
|
||||||
scripts/run.sh --start
|
scripts/run.sh --start
|
||||||
|
|
@ -110,8 +83,6 @@ cp config/config.example.ini config.ini # then edit
|
||||||
| `--mock-camera` | Use a simulated camera — no Vimba hardware needed. |
|
| `--mock-camera` | Use a simulated camera — no Vimba hardware needed. |
|
||||||
| `--mock-serial` | Use a simulated motor controller — no serial hardware needed. |
|
| `--mock-serial` | Use a simulated motor controller — no serial hardware needed. |
|
||||||
| `--tui` / `--no-tui` | Show the full-screen terminal dashboard / force the headless console (overrides `[UI] enable_tui`). |
|
| `--tui` / `--no-tui` | Show the full-screen terminal dashboard / force the headless console (overrides `[UI] enable_tui`). |
|
||||||
| `--no-lock` | Skip the single-instance lock (dev only — for a second mock-only instance). |
|
|
||||||
| `--lock-file <path>` | Use a specific lockfile instead of the default per-uid path. |
|
|
||||||
| `--log-level <lvl>` | `trace`, `debug`, `info`, `warn`, `error`, or `off`. |
|
| `--log-level <lvl>` | `trace`, `debug`, `info`, `warn`, `error`, or `off`. |
|
||||||
| `--trace <cats>` | Verbatim wire tracing; comma list of `serial,mqtt,camera,control,all,none`. |
|
| `--trace <cats>` | Verbatim wire tracing; comma list of `serial,mqtt,camera,control,all,none`. |
|
||||||
| `-h, --help` | Print options and exit. |
|
| `-h, --help` | Print options and exit. |
|
||||||
|
|
|
||||||
60
deploy.sh
60
deploy.sh
|
|
@ -7,20 +7,11 @@
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./deploy.sh rsync + remote configure + build
|
# ./deploy.sh rsync + remote configure + build
|
||||||
# ./deploy.sh --run ... then start the app on the device, detached, and return
|
# ./deploy.sh --run ... then run the app over ssh (RUN_ARGS, ctrl-c to stop)
|
||||||
# ./deploy.sh --run --home ... and run the endstop-finding home sequence at startup
|
# ./deploy.sh --run --home ... and run the endstop-finding home sequence at startup
|
||||||
# ./deploy.sh --run --attach ... start it, then attach straight away (the usual one)
|
|
||||||
# ./deploy.sh --attach attach to the app already running (no sync, no build)
|
|
||||||
# ./deploy.sh --status is it running on the device?
|
|
||||||
# ./deploy.sh --stop shut the running app down cleanly
|
|
||||||
# ./deploy.sh --clean wipe the remote build dir first (fresh configure)
|
# ./deploy.sh --clean wipe the remote build dir first (fresh configure)
|
||||||
# ./deploy.sh --check-deps only check the remote build dependencies
|
# ./deploy.sh --check-deps only check the remote build dependencies
|
||||||
# ./deploy.sh --run --force skip the placeholder-config guard before running
|
# ./deploy.sh --run --force skip the placeholder-config guard before running
|
||||||
# ./deploy.sh --run --foreground run attached to this terminal instead (debugging)
|
|
||||||
#
|
|
||||||
# --run starts the app inside a tmux session on the device (scripts/fgc), so it
|
|
||||||
# survives this ssh connection closing — reattach later with --attach. Only one
|
|
||||||
# instance can run at a time; the binary itself enforces that with a lockfile.
|
|
||||||
#
|
#
|
||||||
# Flags combine, e.g. ./deploy.sh --clean --run
|
# Flags combine, e.g. ./deploy.sh --clean --run
|
||||||
#
|
#
|
||||||
|
|
@ -49,8 +40,6 @@ RUN=0
|
||||||
CHECK_DEPS=0
|
CHECK_DEPS=0
|
||||||
FORCE=0
|
FORCE=0
|
||||||
HOME_INIT=0
|
HOME_INIT=0
|
||||||
FOREGROUND=0
|
|
||||||
SESSION_CMD=""
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
|
|
@ -58,35 +47,10 @@ for arg in "$@"; do
|
||||||
--home|--init) HOME_INIT=1 ;;
|
--home|--init) HOME_INIT=1 ;;
|
||||||
--check-deps) CHECK_DEPS=1 ;;
|
--check-deps) CHECK_DEPS=1 ;;
|
||||||
--force) FORCE=1 ;;
|
--force) FORCE=1 ;;
|
||||||
--foreground) FOREGROUND=1 ;;
|
|
||||||
--attach) SESSION_CMD="attach" ;;
|
|
||||||
--status) SESSION_CMD="status" ;;
|
|
||||||
--stop) SESSION_CMD="stop" ;;
|
|
||||||
*) echo "unknown option: $arg" >&2; exit 1 ;;
|
*) echo "unknown option: $arg" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$SESSION_CMD" == "attach" && "$FOREGROUND" == "1" ]]; then
|
|
||||||
echo "error: --attach and --foreground are contradictory (--foreground never detaches)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" && "$RUN" == "1" ]]; then
|
|
||||||
echo "error: --$SESSION_CMD cannot be combined with --run" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Talk to the app already on the device — no sync, no build. `--run --attach` is
|
|
||||||
# the exception: there the attach happens at the very end, after the build and
|
|
||||||
# start, so fall through to those instead of exiting here.
|
|
||||||
if [[ -n "$SESSION_CMD" ]] && [[ "$SESSION_CMD" != "attach" || "$RUN" != "1" ]]; then
|
|
||||||
# -t only for attach: it needs a real tty for the dashboard. status/stop
|
|
||||||
# produce plain output and should stay pipeable.
|
|
||||||
TTY_FLAG=(); [[ "$SESSION_CMD" == "attach" ]] && TTY_FLAG=(-t)
|
|
||||||
# shellcheck disable=SC2029
|
|
||||||
exec ssh "${TTY_FLAG[@]}" "$REMOTE_HOST" \
|
|
||||||
"cd '$REMOTE_DIR' && exec scripts/fgc $SESSION_CMD"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Homing is opt-in: append --init only when --home is passed, so a plain run
|
# Homing is opt-in: append --init only when --home is passed, so a plain run
|
||||||
# brings up the TUI without driving the endstop-finding sequence.
|
# brings up the TUI without driving the endstop-finding sequence.
|
||||||
if [[ $HOME_INIT -eq 1 && "$RUN_ARGS" != *--init* ]]; then
|
if [[ $HOME_INIT -eq 1 && "$RUN_ARGS" != *--init* ]]; then
|
||||||
|
|
@ -240,29 +204,9 @@ if [[ "$RUN" == "1" ]]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$FOREGROUND" == "1" ]]; then
|
echo ">> running on $REMOTE_HOST (ctrl-c to stop)"
|
||||||
echo ">> running on $REMOTE_HOST, attached to this terminal (ctrl-c to stop)"
|
|
||||||
# -t for a real tty so the capture loop's logs stream and ctrl-c propagates.
|
# -t for a real tty so the capture loop's logs stream and ctrl-c propagates.
|
||||||
# cwd = REMOTE_DIR so ./config.ini and the output dir resolve.
|
# cwd = REMOTE_DIR so ./config.ini and the output dir resolve.
|
||||||
# shellcheck disable=SC2029
|
# shellcheck disable=SC2029
|
||||||
ssh -t "$REMOTE_HOST" "$RESOLVE_VIMBA_ENV cd '$REMOTE_DIR' && exec ./build/fire_gimbal_control $RUN_ARGS"
|
ssh -t "$REMOTE_HOST" "$RESOLVE_VIMBA_ENV cd '$REMOTE_DIR' && exec ./build/fire_gimbal_control $RUN_ARGS"
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">> starting on $REMOTE_HOST, detached"
|
|
||||||
# No -t and no RESOLVE_VIMBA_ENV here: scripts/fgc puts the app in a tmux
|
|
||||||
# session on the device and resolves the Vimba environment inside the pane,
|
|
||||||
# so it neither needs this ssh session's tty nor inherits its environment.
|
|
||||||
# This ssh call returns as soon as the app is up (or reports why it isn't).
|
|
||||||
# shellcheck disable=SC2029
|
|
||||||
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && exec scripts/fgc start $RUN_ARGS"
|
|
||||||
|
|
||||||
if [[ "$SESSION_CMD" == "attach" ]]; then
|
|
||||||
echo ">> attaching (Ctrl-b d detaches and leaves it running; 'q' quits the app)"
|
|
||||||
# shellcheck disable=SC2029
|
|
||||||
exec ssh -t "$REMOTE_HOST" "cd '$REMOTE_DIR' && exec scripts/fgc attach"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">> attach with: ./deploy.sh --attach (Ctrl-b d detaches, 'q' quits the app)"
|
|
||||||
echo ">> stop with: ./deploy.sh --stop"
|
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -141,80 +141,8 @@ Watch for: `Loaded config`, `Serial controller started`, `Opened camera ...`, `M
|
||||||
`Saved .../RGB/<timestamp>.jxl` after the first capture. Type `exit` (or Ctrl-D) to stop. To validate without
|
`Saved .../RGB/<timestamp>.jxl` after the first capture. Type `exit` (or Ctrl-D) to stop. To validate without
|
||||||
cameras first: add `--mock-camera --mock-serial --no-mqtt`.
|
cameras first: add `--mock-camera --mock-serial --no-mqtt`.
|
||||||
|
|
||||||
This run is attached to your terminal and dies with the ssh session — fine for a first check, not for
|
|
||||||
leaving it running. For that, use section 6b.
|
|
||||||
|
|
||||||
### 6b. Running detached (`scripts/fgc`)
|
|
||||||
|
|
||||||
The tower is operated over ssh, so the program has to survive the connection closing. `scripts/fgc` runs
|
|
||||||
it inside a tmux session, which owns the pty:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/fire_gimbal_control
|
|
||||||
scripts/fgc start --start # starts detached, returns as soon as it is up
|
|
||||||
scripts/fgc status # running? since when? which PID?
|
|
||||||
scripts/fgc attach # the dashboard — Ctrl-b d detaches, leaves it running
|
|
||||||
scripts/fgc stop # SIGTERM -> the same clean teardown as `exit`
|
|
||||||
scripts/fgc peek # snapshot of the pane without attaching
|
|
||||||
```
|
|
||||||
|
|
||||||
**`Ctrl-b d` detaches. The dashboard's own `q` key exits the program.** That is the one thing to get
|
|
||||||
right while attached.
|
|
||||||
|
|
||||||
`scripts/fgc stop` sends SIGTERM and waits up to 30s; the program handles it with the same shutdown path
|
|
||||||
as the `exit` command (stop capture, close the camera, save the exposure store). Do **not** `kill -9` —
|
|
||||||
an unclean exit leaves the camera wedged for the next run. If it will not stop, attach and look at why.
|
|
||||||
|
|
||||||
`scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died
|
|
||||||
rather than silently discarding it.
|
|
||||||
|
|
||||||
Quitting the dashboard with `q` while attached ends the tmux session too, so you land straight back in
|
|
||||||
your shell — `exit` then closes ssh. A run that *fails* is the exception: its pane is kept so you can read
|
|
||||||
why (`remain-on-exit failed`), and `fgc status` shows it until `fgc stop` or the next `fgc start` clears it.
|
|
||||||
|
|
||||||
A kept pane has **no process behind it** — keys and Ctrl-C do nothing, which reads as a hung ssh session.
|
|
||||||
`Ctrl-b d` still detaches (tmux, not the pane, handles it); the session's status line spells that out along
|
|
||||||
the bottom of the screen. As a last resort `Enter ~ .` closes the ssh client itself from your end.
|
|
||||||
|
|
||||||
> **Why `_run` waits on the app instead of `exec`ing it.** When the app is itself the pane's process, tmux
|
|
||||||
> never records an exit status for it — `#{pane_dead_status}` comes back empty and the process lingers as a
|
|
||||||
> zombie — so `remain-on-exit failed` cannot tell success from failure and keeps *every* finished pane,
|
|
||||||
> including a clean `q`. That is what used to strand an operator on a frozen dead pane. Interposing a shell
|
|
||||||
> that reaps the child and exits with its status gives tmux a real status. Measured on the device: 3/3 panes
|
|
||||||
> kept when exec'd, 4/4 sessions closed cleanly when parented. The wrapper forwards SIGTERM/SIGINT/SIGHUP,
|
|
||||||
> so `fgc stop` still reaches the app and runs the full teardown.
|
|
||||||
|
|
||||||
### 6c. The single-instance lock
|
|
||||||
|
|
||||||
Two instances would fight over the motor's serial port, the camera, and the exposure store. The binary
|
|
||||||
prevents that itself: at startup it takes an exclusive `flock` on
|
|
||||||
|
|
||||||
- `/run/user/<uid>/fire_gimbal_control.lock` when that directory exists, else
|
|
||||||
- `/tmp/fire_gimbal_control-<uid>.lock`
|
|
||||||
|
|
||||||
and, if it is already held, exits with status **2** and:
|
|
||||||
|
|
||||||
```
|
|
||||||
Cannot start: another instance is already running (PID 1234); attach to it with: scripts/fgc attach
|
|
||||||
```
|
|
||||||
|
|
||||||
The path is derived from the uid and not from `$XDG_RUNTIME_DIR` on purpose: that variable is set in an
|
|
||||||
interactive ssh shell but not in a non-interactive `ssh host cmd` one, and a lock the two launch routes
|
|
||||||
disagree about would exclude nothing.
|
|
||||||
|
|
||||||
Because the check lives in the binary, it covers every route in — `scripts/fgc`, systemd, or a bare
|
|
||||||
`./fire_gimbal_control`. Nothing needs cleaning up after a crash or a power cut: the kernel releases an
|
|
||||||
`flock` when the holder dies, so a leftover lockfile with a dead PID in it never blocks a launch. Use
|
|
||||||
`--no-lock` only to run a second mock-only instance during development, and `--lock-file <path>` to
|
|
||||||
point at a different lockfile.
|
|
||||||
|
|
||||||
## 7. Install the systemd service
|
## 7. Install the systemd service
|
||||||
|
|
||||||
An **alternative** to section 6b, not a companion to it: systemd gives you start-at-boot and
|
|
||||||
restart-on-crash, but no attachable dashboard — you get `journalctl` instead. Pick one. If you run both by
|
|
||||||
accident the second one to start fails on the single-instance lock (section 6c), so the failure is loud
|
|
||||||
rather than a pair of processes corrupting each other's state.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp scripts/fire-gimbal-control.service /etc/systemd/system/
|
sudo cp scripts/fire-gimbal-control.service /etc/systemd/system/
|
||||||
sudoedit /etc/systemd/system/fire-gimbal-control.service
|
sudoedit /etc/systemd/system/fire-gimbal-control.service
|
||||||
|
|
@ -274,6 +202,3 @@ If `CMakeLists.txt` or options changed, re-run the `cmake -B build ...` configur
|
||||||
| `No camera found` / Vimba startup error | SDK installed, GenTL path set, `ldconfig`, NIC subnet, camera IDs in config |
|
| `No camera found` / Vimba startup error | SDK installed, GenTL path set, `ldconfig`, NIC subnet, camera IDs in config |
|
||||||
| `MQTT connect failed` (degraded mode) | broker reachable, credentials env, firewall |
|
| `MQTT connect failed` (degraded mode) | broker reachable, credentials env, firewall |
|
||||||
| Runs but no images | capture started (`--start` or `start` command), `output_dir` writable by `ggs` |
|
| Runs but no images | capture started (`--start` or `start` command), `output_dir` writable by `ggs` |
|
||||||
| `another instance is already running` (exit 2) | `scripts/fgc status`; a systemd unit and a tmux session both running (section 6c) |
|
|
||||||
| Program dies when ssh disconnects | started with `run.sh`/`--foreground` instead of `scripts/fgc start` (section 6b) |
|
|
||||||
| `No camera found` only when started via `scripts/fgc` | GenTL path not resolved in the pane — check `scripts/fgc peek`, and `/etc/profile.d/*Vimba*GenTL*.sh` or `VIMBA_CTI_PATH` |
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
// Refuses to let two copies of the program run at once. Two instances would
|
|
||||||
// fight over the motor's serial port, the camera, and the exposure store, so
|
|
||||||
// the second launch has to fail loudly rather than half-work.
|
|
||||||
//
|
|
||||||
// The mechanism is an advisory flock(2) on a lockfile holding the owner's PID.
|
|
||||||
// The lock lives in the binary (not the launcher script) so every route in -
|
|
||||||
// scripts/fgc, systemd, or a bare ./fire_gimbal_control over ssh - is guarded.
|
|
||||||
//
|
|
||||||
// There is deliberately no stale-lock cleanup: the kernel drops an flock when
|
|
||||||
// the holding process dies, including a SIGKILL or a power cut, so a lockfile
|
|
||||||
// left behind with a dead PID in it never blocks the next launch. The PID
|
|
||||||
// written inside is purely for the "already running as PID N" message.
|
|
||||||
//
|
|
||||||
// auto lock = SingleInstance::acquire(SingleInstance::defaultLockPath());
|
|
||||||
// if (!lock) { std::cerr << lock.error() << "\n"; return 2; }
|
|
||||||
// ... lock stays in scope for the lifetime of the run ...
|
|
||||||
class SingleInstance {
|
|
||||||
public:
|
|
||||||
// Default lockfile: /run/user/<uid>/fire_gimbal_control.lock when that
|
|
||||||
// directory exists, else /tmp/fire_gimbal_control-<uid>.lock. Derived from
|
|
||||||
// the uid rather than $XDG_RUNTIME_DIR so it is identical whether the
|
|
||||||
// program was launched from an interactive shell, a non-interactive
|
|
||||||
// `ssh host cmd`, or a tmux pane - see the note in the .cpp.
|
|
||||||
static std::string defaultLockPath();
|
|
||||||
|
|
||||||
// Try to take the lock. Never blocks. On failure the returned object is
|
|
||||||
// falsey and error() says why (including the holder's PID when we could
|
|
||||||
// read it). Always check before use.
|
|
||||||
static SingleInstance acquire(const std::string& path);
|
|
||||||
|
|
||||||
// A default-constructed instance holds nothing; useful as a placeholder
|
|
||||||
// when locking is disabled (--no-lock).
|
|
||||||
SingleInstance() = default;
|
|
||||||
~SingleInstance();
|
|
||||||
|
|
||||||
SingleInstance(SingleInstance&& other) noexcept;
|
|
||||||
SingleInstance& operator=(SingleInstance&& other) noexcept;
|
|
||||||
SingleInstance(const SingleInstance&) = delete;
|
|
||||||
SingleInstance& operator=(const SingleInstance&) = delete;
|
|
||||||
|
|
||||||
bool held() const { return fd_ >= 0; }
|
|
||||||
explicit operator bool() const { return held(); }
|
|
||||||
|
|
||||||
// Human-readable reason the acquire failed; empty when held().
|
|
||||||
const std::string& error() const { return error_; }
|
|
||||||
// PID of the process holding the lock, or 0 if unknown. Only meaningful
|
|
||||||
// after a failed acquire.
|
|
||||||
int holderPid() const { return holder_pid_; }
|
|
||||||
const std::string& path() const { return path_; }
|
|
||||||
|
|
||||||
// Release early (also done by the destructor). Safe to call twice.
|
|
||||||
void release();
|
|
||||||
|
|
||||||
private:
|
|
||||||
int fd_ = -1;
|
|
||||||
int holder_pid_ = 0;
|
|
||||||
std::string path_;
|
|
||||||
std::string error_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
// Viewport arithmetic for the scrollable TUI panes. Pure integer math with no
|
|
||||||
// FTXUI types, so it lives in fgc_core and is unit-testable without a terminal -
|
|
||||||
// mirroring UiSnapshot's formatting helpers.
|
|
||||||
//
|
|
||||||
// The model is a line-anchored viewport: `top` is the first body LINE we want
|
|
||||||
// visible. Heights come from the previous frame's measurements, so every entry
|
|
||||||
// point has to tolerate `view_h == 0` (nothing measured yet) without dividing by
|
|
||||||
// zero or handing back a position the renderer can't honour.
|
|
||||||
|
|
||||||
// Largest valid `top` for a body of `content_h` lines in a `view_h` window.
|
|
||||||
// 0 when it all fits, and 0 while the view is still unmeasured.
|
|
||||||
int scrollMax(int content_h, int view_h);
|
|
||||||
|
|
||||||
int scrollClamp(int top, int content_h, int view_h);
|
|
||||||
|
|
||||||
// One line per keypress; `delta` is +/-1.
|
|
||||||
int scrollBy(int top, int delta, int content_h, int view_h);
|
|
||||||
|
|
||||||
// A screenful less two lines of overlap, so the eye keeps its place. `dir` is
|
|
||||||
// +/-1; the step is at least 1 even in a one-line pane.
|
|
||||||
int scrollPage(int top, int dir, int content_h, int view_h);
|
|
||||||
|
|
||||||
// Rows per column for a wrapped grid. Short grids keep a compact multi-column
|
|
||||||
// look (at least `min_rows` per column); past `max_cols` columns the grid grows
|
|
||||||
// DOWNWARD instead of off the right edge, where vertical scrolling can reach it.
|
|
||||||
std::size_t gridRowsPerColumn(std::size_t item_count, std::size_t max_cols, std::size_t min_rows);
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -54,10 +54,6 @@ private:
|
||||||
// Log ring buffer (newest last), filled by the Logger sink.
|
// Log ring buffer (newest last), filled by the Logger sink.
|
||||||
std::mutex log_mutex_;
|
std::mutex log_mutex_;
|
||||||
std::deque<LogLine> log_;
|
std::deque<LogLine> log_;
|
||||||
// Lines evicted from the FRONT since startup. A frozen scrollback position is
|
|
||||||
// an index from the start of the buffer, so every eviction shifts the content
|
|
||||||
// under it; the renderer subtracts the delta to hold the view still.
|
|
||||||
size_t log_dropped_ = 0;
|
|
||||||
static constexpr size_t kLogCap = 500;
|
static constexpr size_t kLogCap = 500;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
20
main.cpp
20
main.cpp
|
|
@ -2,7 +2,6 @@
|
||||||
#include "fgc/Config.h"
|
#include "fgc/Config.h"
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
#include "fgc/Paths.h"
|
#include "fgc/Paths.h"
|
||||||
#include "fgc/SingleInstance.h"
|
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
|
@ -26,9 +25,6 @@ int main(int argc, char* argv[]) {
|
||||||
"use a simulated ambient temp/humidity sensor (implies enable_env)")
|
"use a simulated ambient temp/humidity sensor (implies enable_env)")
|
||||||
("tui", po::bool_switch(), "show the full-screen terminal dashboard")
|
("tui", po::bool_switch(), "show the full-screen terminal dashboard")
|
||||||
("no-tui", po::bool_switch(), "force the headless line console (overrides config)")
|
("no-tui", po::bool_switch(), "force the headless line console (overrides config)")
|
||||||
("no-lock", po::bool_switch(),
|
|
||||||
"skip the single-instance lock (dev only: for a second mock-only instance)")
|
|
||||||
("lock-file", po::value<std::string>(), "path to the single-instance lockfile")
|
|
||||||
("log-level", po::value<std::string>(), "trace|debug|info|warn|error|off")
|
("log-level", po::value<std::string>(), "trace|debug|info|warn|error|off")
|
||||||
("trace", po::value<std::string>(),
|
("trace", po::value<std::string>(),
|
||||||
"verbatim wire trace, comma list: serial,mqtt,camera,control,all,none");
|
"verbatim wire trace, comma list: serial,mqtt,camera,control,all,none");
|
||||||
|
|
@ -65,22 +61,6 @@ int main(int argc, char* argv[]) {
|
||||||
}
|
}
|
||||||
LOG_INFO << "Loaded config: " << *cfg_path;
|
LOG_INFO << "Loaded config: " << *cfg_path;
|
||||||
|
|
||||||
// Only one instance may run: a second would fight the first over the motor's
|
|
||||||
// serial port, the camera, and the exposure store. The lock is held for the
|
|
||||||
// whole run and released when this scope ends (or the process dies).
|
|
||||||
SingleInstance lock;
|
|
||||||
if (!vm["no-lock"].as<bool>()) {
|
|
||||||
const std::string lock_path = vm.count("lock-file")
|
|
||||||
? paths::expandUser(vm["lock-file"].as<std::string>())
|
|
||||||
: SingleInstance::defaultLockPath();
|
|
||||||
lock = SingleInstance::acquire(lock_path);
|
|
||||||
if (!lock) {
|
|
||||||
std::cerr << "Cannot start: " << lock.error() << "\n";
|
|
||||||
return 2; // distinct from 1 so scripts/fgc can tell this apart
|
|
||||||
}
|
|
||||||
LOG_INFO << "Single-instance lock held: " << lock.path();
|
|
||||||
}
|
|
||||||
|
|
||||||
RuntimeOptions opts;
|
RuntimeOptions opts;
|
||||||
opts.init = vm["init"].as<bool>();
|
opts.init = vm["init"].as<bool>();
|
||||||
opts.start = vm["start"].as<bool>();
|
opts.start = vm["start"].as<bool>();
|
||||||
|
|
|
||||||
342
scripts/fgc
342
scripts/fgc
|
|
@ -1,342 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# fgc — run fire_gimbal_control detached from the terminal, in a tmux session.
|
|
||||||
#
|
|
||||||
# The tower is operated over ssh. Launched directly, the program is a child of
|
|
||||||
# the ssh session's shell and dies with it — mid-scan, and with the camera torn
|
|
||||||
# down uncleanly (which wedges it for the next run). Here tmux owns the pty, so
|
|
||||||
# disconnecting is invisible to the program.
|
|
||||||
#
|
|
||||||
# scripts/fgc start [args...] start it detached (args go to the binary)
|
|
||||||
# scripts/fgc attach open the dashboard; Ctrl-b d to leave it running
|
|
||||||
# scripts/fgc status is it running, since when, as which PID
|
|
||||||
# scripts/fgc stop clean shutdown (camera + motor teardown)
|
|
||||||
# scripts/fgc peek print what the pane shows right now
|
|
||||||
# scripts/fgc restart [args...] stop, then start
|
|
||||||
#
|
|
||||||
# Detaching is Ctrl-b d. NOTE: the dashboard's own `q` key *exits the program*,
|
|
||||||
# it does not detach — that is the one thing to get right when attached.
|
|
||||||
#
|
|
||||||
# Only one instance may run. The binary enforces that itself with a lockfile
|
|
||||||
# (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is
|
|
||||||
# refused too, not just a second `fgc start`.
|
|
||||||
#
|
|
||||||
# Env: FGC_SESSION overrides the tmux session name (default "fgc").
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
repo="$(cd "$here/.." && pwd)"
|
|
||||||
session="${FGC_SESSION:-fgc}"
|
|
||||||
target="=$session" # "=" makes tmux match the name exactly, not as a prefix
|
|
||||||
|
|
||||||
die() { echo "fgc: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
need_tmux() {
|
|
||||||
command -v tmux >/dev/null 2>&1 || die "tmux is not installed (apt-get install tmux)"
|
|
||||||
}
|
|
||||||
|
|
||||||
session_exists() { tmux has-session -t "$target" 2>/dev/null; }
|
|
||||||
|
|
||||||
# "1" if the pane's program has exited, "0" if alive, "" if there is no session.
|
|
||||||
# remain-on-exit keeps a finished pane around, so a session existing is not on
|
|
||||||
# its own proof that the program is running.
|
|
||||||
pane_dead() { tmux list-panes -t "$target" -F '#{pane_dead}' 2>/dev/null | head -1; }
|
|
||||||
|
|
||||||
running() { [[ "$(pane_dead)" == "0" ]]; }
|
|
||||||
|
|
||||||
# PID of the pane's process: the `_run` wrapper shell, which forwards signals to
|
|
||||||
# the app and reports its exit status to tmux. For the app's own PID use
|
|
||||||
# app_pid() — the lockfile is written by the program itself.
|
|
||||||
pane_pid() { tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1; }
|
|
||||||
|
|
||||||
# PID of fire_gimbal_control itself, from the lockfile it writes at startup.
|
|
||||||
app_pid() {
|
|
||||||
local f; f="$(lock_path)"
|
|
||||||
[[ -e "$f" ]] || return 1
|
|
||||||
local p; read -r p <"$f" 2>/dev/null || return 1
|
|
||||||
[[ -n "$p" ]] && echo "$p"
|
|
||||||
}
|
|
||||||
|
|
||||||
# capture-pane needs a pane target; a session name alone gets "can't find pane".
|
|
||||||
# %N pane ids are unambiguous, so resolve one rather than guessing at "fgc:0.0".
|
|
||||||
pane_id() { tmux list-panes -t "$target" -F '#{pane_id}' 2>/dev/null | head -1; }
|
|
||||||
|
|
||||||
# Mirrors SingleInstance::defaultLockPath(). Kept env-independent for the same
|
|
||||||
# reason it is there: $XDG_RUNTIME_DIR is absent in a non-interactive ssh shell.
|
|
||||||
lock_path() {
|
|
||||||
local uid; uid="$(id -u)"
|
|
||||||
if [[ -d "/run/user/$uid" ]]; then
|
|
||||||
echo "/run/user/$uid/fire_gimbal_control.lock"
|
|
||||||
else
|
|
||||||
echo "/tmp/fire_gimbal_control-$uid.lock"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Is the lock actually held right now? Asked by trying to take it, not by
|
|
||||||
# checking whether the recorded PID exists: with remain-on-exit set, tmux does
|
|
||||||
# not reap the pane's process until the pane is destroyed, so a finished run
|
|
||||||
# leaves a zombie that `kill -0` happily reports as alive. The lock itself is
|
|
||||||
# the only thing that answers the question, and the kernel drops it on death.
|
|
||||||
lock_held() {
|
|
||||||
local f="$1"
|
|
||||||
[[ -e "$f" ]] || return 1
|
|
||||||
! flock -n "$f" true 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Vimba X needs the GenTL transport-layer dir on GENICAM_GENTL64_PATH or
|
|
||||||
# VmbStartup() fails with "Could not start Vimba X API". The SDK installs that
|
|
||||||
# via /etc/profile.d, which non-interactive ssh shells don't source. Resolving it
|
|
||||||
# *here* — i.e. inside the pane, at launch — rather than in the caller's shell
|
|
||||||
# matters: a tmux server left over from an earlier, differently-configured ssh
|
|
||||||
# session would otherwise hand the new session its stale environment.
|
|
||||||
resolve_vimba_env() {
|
|
||||||
local s d
|
|
||||||
# The SDK's profile.d script reads GENICAM_GENTL64_PATH before setting it, so
|
|
||||||
# sourcing it under `set -u` aborts with "unbound variable" and the app never
|
|
||||||
# launches. Relax nounset for the duration of the source only.
|
|
||||||
set +u
|
|
||||||
for s in /etc/profile.d/*Vimba*GenTL*.sh; do
|
|
||||||
[[ -f "$s" ]] && . "$s"
|
|
||||||
done
|
|
||||||
set -u
|
|
||||||
if [[ -z "${GENICAM_GENTL64_PATH:-}" ]]; then
|
|
||||||
for d in "${VIMBA_CTI_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do
|
|
||||||
if [[ -n "$d" && -d "$d" ]]; then export GENICAM_GENTL64_PATH="$d"; break; fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_start() {
|
|
||||||
need_tmux
|
|
||||||
if running; then
|
|
||||||
die "already running (session '$session') — use 'fgc attach', or 'fgc restart'"
|
|
||||||
fi
|
|
||||||
if session_exists; then
|
|
||||||
# A dead pane left over by remain-on-exit from a previous run. Clear it,
|
|
||||||
# but say so — it is the only trace of how that run ended.
|
|
||||||
echo "fgc: clearing the finished session from a previous run" >&2
|
|
||||||
tmux kill-session -t "$target" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# tmux hands the command to `sh -c`, so quote each forwarded argument rather
|
|
||||||
# than relying on the split surviving the round trip. No `exec` here, and no
|
|
||||||
# exec in `_run` either — see the comment there for why the wrapper shell has
|
|
||||||
# to stay in place. `fgc stop` signals the wrapper, which forwards.
|
|
||||||
local launch
|
|
||||||
launch="$(printf '%q ' "$here/fgc" _run "$@")"
|
|
||||||
|
|
||||||
# remain-on-exit=failed, not =on: a *failed* start must leave its output on
|
|
||||||
# screen rather than destroying the session and looking like nothing
|
|
||||||
# happened, but a clean exit must take the session with it. With =on, quitting
|
|
||||||
# the dashboard left an operator staring at a frozen dead pane with no process
|
|
||||||
# behind it — keys and Ctrl-C did nothing, and it read as a hung ssh session.
|
|
||||||
#
|
|
||||||
# It is a *window* option, and a pane that dies immediately takes the window
|
|
||||||
# with it before the option can be set — so create the session on a
|
|
||||||
# placeholder, set the option, then respawn the pane with the real command.
|
|
||||||
# -c: the program resolves ./config.ini and the output dir from the cwd. The
|
|
||||||
# pane runs this script's hidden `_run`, so the Vimba environment is resolved
|
|
||||||
# inside the pane; run.sh finds the binary, one place only.
|
|
||||||
tmux new-session -d -s "$session" -c "$repo" 'sleep 86400'
|
|
||||||
# A window option needs a window target; a session name alone gets
|
|
||||||
# "no such window". Resolve the window's own id (@N) and use that.
|
|
||||||
local win; win="$(tmux list-windows -t "$target" -F '#{window_id}' | head -1)"
|
|
||||||
local pane; pane="$(pane_id)"
|
|
||||||
# "failed" needs tmux >= 3.2; fall back to "on" rather than leaving it unset,
|
|
||||||
# where a failed start would vanish without a trace.
|
|
||||||
tmux set-option -w -t "$win" remain-on-exit failed >/dev/null 2>&1 \
|
|
||||||
|| tmux set-option -w -t "$win" remain-on-exit on >/dev/null
|
|
||||||
# Spell the way out along the bottom of the screen. Without it there is no
|
|
||||||
# clue on screen that Ctrl-b d exists — and if the program dies, the pane
|
|
||||||
# freezes with no process behind it, so keys and Ctrl-C do nothing and it
|
|
||||||
# reads as a hung ssh session. tmux's status line stays alive either way.
|
|
||||||
# These are *session* options, and set-option rejects the "=name" exact-match
|
|
||||||
# form that every other subcommand here takes ("no such session: =fgc"), so
|
|
||||||
# target the pane and let tmux resolve the session from it.
|
|
||||||
tmux set-option -t "$pane" status on >/dev/null
|
|
||||||
tmux set-option -t "$pane" status-style 'bg=colour24,fg=colour255' >/dev/null
|
|
||||||
tmux set-option -t "$pane" status-left ' fire-gimbal-control ' >/dev/null
|
|
||||||
tmux set-option -t "$pane" status-left-length 30 >/dev/null
|
|
||||||
tmux set-option -t "$pane" status-right-length 80 >/dev/null
|
|
||||||
tmux set-option -t "$pane" status-right \
|
|
||||||
' Ctrl-b d = detach, keeps running | q = quit the program ' >/dev/null
|
|
||||||
|
|
||||||
tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null
|
|
||||||
|
|
||||||
# Give it a moment to fail fast (bad config, lock already held, no camera).
|
|
||||||
sleep 2
|
|
||||||
local dead; dead="$(pane_dead)"
|
|
||||||
if [[ -z "$dead" ]]; then
|
|
||||||
# No session at all: the program exited 0 within the 2s window, so
|
|
||||||
# remain-on-exit=failed tore it down and took the output with it.
|
|
||||||
die "session '$session' is gone — the program exited immediately (status 0).
|
|
||||||
Reproduce it in the foreground to see why: scripts/run.sh $*"
|
|
||||||
fi
|
|
||||||
if [[ "$dead" == "1" ]]; then
|
|
||||||
local status
|
|
||||||
status="$(tmux list-panes -t "$target" -F '#{pane_dead_status}' | head -1)"
|
|
||||||
echo "fgc: failed to start (exit ${status:-?}):" >&2
|
|
||||||
tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -20 >&2
|
|
||||||
tmux kill-session -t "$target" 2>/dev/null || true
|
|
||||||
exit "${status:-1}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "fgc: started detached as PID $(pane_pid) (session '$session')"
|
|
||||||
echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' quits the program)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hidden: the command tmux runs inside the pane. Not for direct use.
|
|
||||||
#
|
|
||||||
# The app is run as a CHILD and waited on, rather than exec'd. That looks like a
|
|
||||||
# pointless extra process, and it is load-bearing: when the app is itself the
|
|
||||||
# pane's process, tmux never records an exit status for it (#{pane_dead_status}
|
|
||||||
# comes back empty and the process lingers as a zombie), so remain-on-exit=failed
|
|
||||||
# cannot tell success from failure and keeps *every* finished pane — which is
|
|
||||||
# what left an operator staring at a frozen dead pane after pressing q. With a
|
|
||||||
# shell in between to reap the child and exit with its status, tmux gets a real
|
|
||||||
# status and tears the session down on a clean quit. Measured: 3/3 kept when
|
|
||||||
# exec'd, 3/3 clean when parented.
|
|
||||||
cmd__run() {
|
|
||||||
resolve_vimba_env
|
|
||||||
|
|
||||||
# <&0 is required: bash redirects a background command's stdin from
|
|
||||||
# /dev/null unless it is given one explicitly, which would leave the
|
|
||||||
# dashboard unable to read a single keystroke.
|
|
||||||
"$here/run.sh" "$@" <&0 &
|
|
||||||
local child=$!
|
|
||||||
|
|
||||||
# Forward the signals `fgc stop` and a pane kill use, so the app still runs
|
|
||||||
# its own clean teardown (stop capture, close camera, save exposure store)
|
|
||||||
# instead of being killed under the wrapper.
|
|
||||||
trap 'kill -TERM "$child" 2>/dev/null' TERM
|
|
||||||
trap 'kill -INT "$child" 2>/dev/null' INT
|
|
||||||
trap 'kill -HUP "$child" 2>/dev/null' HUP
|
|
||||||
|
|
||||||
# A trapped signal makes `wait` return 128+n while the child is still
|
|
||||||
# shutting down, so keep waiting until it is actually gone before reporting.
|
|
||||||
local rc=0
|
|
||||||
wait "$child"; rc=$?
|
|
||||||
while kill -0 "$child" 2>/dev/null; do
|
|
||||||
wait "$child"; rc=$?
|
|
||||||
done
|
|
||||||
exit "$rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_attach() {
|
|
||||||
need_tmux
|
|
||||||
running || die "not running — start it with 'fgc start'"
|
|
||||||
if [[ -n "${TMUX:-}" ]]; then
|
|
||||||
die "already inside tmux; use 'tmux switch-client -t $session'"
|
|
||||||
fi
|
|
||||||
echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' would quit it)" >&2
|
|
||||||
exec tmux attach-session -t "$target"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_status() {
|
|
||||||
need_tmux
|
|
||||||
local lock; lock="$(lock_path)"
|
|
||||||
if ! running; then
|
|
||||||
if session_exists; then
|
|
||||||
echo "fgc: not running — session '$session' holds a finished pane:"
|
|
||||||
tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -10 | sed 's/^/ /'
|
|
||||||
echo " clear it with 'fgc stop' (or just 'fgc start')"
|
|
||||||
else
|
|
||||||
echo "fgc: not running (no tmux session '$session')"
|
|
||||||
fi
|
|
||||||
# The lock outliving the session means an instance is running that fgc
|
|
||||||
# did not start — systemd, or someone's bare ./fire_gimbal_control.
|
|
||||||
if lock_held "$lock"; then
|
|
||||||
local pid; read -r pid <"$lock" || pid="?"
|
|
||||||
echo " but $lock is held by PID $pid — an instance is running outside tmux"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
local app; app="$(app_pid || echo "$(pane_pid)")"
|
|
||||||
echo "fgc: running (session '$session', PID $app)"
|
|
||||||
ps -o lstart=,etime=,args= -p "$app" 2>/dev/null | sed 's/^/ /'
|
|
||||||
echo " lockfile: $lock"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_stop() {
|
|
||||||
need_tmux
|
|
||||||
if ! running; then
|
|
||||||
if session_exists; then
|
|
||||||
tmux kill-session -t "$target" 2>/dev/null || true
|
|
||||||
echo "fgc: cleared the finished session '$session'"
|
|
||||||
else
|
|
||||||
echo "fgc: not running"
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local pid; pid="$(pane_pid)"
|
|
||||||
|
|
||||||
# The pane should be our `_run` wrapper, which forwards the signal to the
|
|
||||||
# app. Signalling something else would look like a clean stop while the
|
|
||||||
# program kept running, so check rather than assume.
|
|
||||||
if ! ps -o args= -p "$pid" 2>/dev/null | grep -qE 'fgc _run|fire_gimbal'; then
|
|
||||||
echo "fgc: pane process $pid is not the fgc launcher:" >&2
|
|
||||||
ps -o args= -p "$pid" 2>/dev/null | sed 's/^/ /' >&2
|
|
||||||
die "refusing to signal it — attach and shut it down by hand"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SIGTERM, not a keystroke: Application installs a SIGTERM handler that runs
|
|
||||||
# the same clean teardown as the `exit` command (stop capture, close the
|
|
||||||
# camera, save the exposure store, park the motor), and unlike sending keys
|
|
||||||
# it works the same in TUI and headless mode. Never SIGKILL — an unclean
|
|
||||||
# exit leaves the camera wedged for the next run.
|
|
||||||
echo "fgc: stopping fire_gimbal_control (PID $(app_pid || echo '?')) via SIGTERM…"
|
|
||||||
kill -TERM "$pid" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Wait on tmux's view of the pane, not on `kill -0`: while a pane is kept
|
|
||||||
# after exit, tmux holds the finished process as a zombie until the pane is
|
|
||||||
# destroyed, so `kill -0` would report it alive forever and every stop would
|
|
||||||
# "time out". `running` covers both endings — a clean exit destroys the
|
|
||||||
# session outright, a failed one leaves the pane behind with dead=1.
|
|
||||||
#
|
|
||||||
# 120s, because shutdown is not instant: the control loop only notices the
|
|
||||||
# flag between ticks, and a capture attempt that is timing out on the camera
|
|
||||||
# (3 tries x 2s) holds it for several seconds before that.
|
|
||||||
local i
|
|
||||||
for i in $(seq 1 120); do
|
|
||||||
running || break
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if running; then
|
|
||||||
echo "fgc: PID $pid still running 120s after SIGTERM — attach and look at why;" >&2
|
|
||||||
echo " do NOT kill -9, that wedges the camera." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
tmux kill-session -t "$target" 2>/dev/null || true
|
|
||||||
echo "fgc: stopped"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_peek() {
|
|
||||||
need_tmux
|
|
||||||
running || die "not running"
|
|
||||||
# In TUI mode FTXUI draws on the alternate screen, so this is a snapshot of
|
|
||||||
# the live dashboard; in headless mode it is the log scrollback.
|
|
||||||
tmux capture-pane -p -S - -t "$(pane_id)"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_restart() {
|
|
||||||
cmd_stop
|
|
||||||
cmd_start "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
sub="${1:-}"
|
|
||||||
[[ $# -gt 0 ]] && shift
|
|
||||||
case "$sub" in
|
|
||||||
start) cmd_start "$@" ;;
|
|
||||||
attach) cmd_attach ;;
|
|
||||||
status) cmd_status ;;
|
|
||||||
stop) cmd_stop ;;
|
|
||||||
peek|logs) cmd_peek ;;
|
|
||||||
restart) cmd_restart "$@" ;;
|
|
||||||
_run) cmd__run "$@" ;;
|
|
||||||
""|-h|--help|help)
|
|
||||||
# The header block above, minus the shebang: one source of truth.
|
|
||||||
awk 'NR>2 && /^#/ {sub(/^# ?/, ""); print; next} NR>2 {exit}' "${BASH_SOURCE[0]}"
|
|
||||||
[[ -z "$sub" ]] && exit 1 || exit 0 ;;
|
|
||||||
*) die "unknown command: $sub (try 'fgc --help')" ;;
|
|
||||||
esac
|
|
||||||
|
|
@ -35,8 +35,7 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"Example: gimbal steps 100000,250000"}},
|
"Example: gimbal steps 100000,250000"}},
|
||||||
{"gimbal nudge <yaw|pitch> <+/-pct>",
|
{"gimbal nudge <yaw|pitch> <+/-pct>",
|
||||||
"Relative step move by a percent of the axis travel.", {
|
"Relative step move by a percent of the axis travel.", {
|
||||||
"Arrow keys do this when NO overlay is open: Left/Right = yaw -/+5%,",
|
"Arrow keys do this: Left/Right = yaw -/+5%, Up/Down = pitch +/-10%.",
|
||||||
"Up/Down = pitch +/-10%. Inside an overlay the arrows scroll instead.",
|
|
||||||
"Example: gimbal nudge yaw -5"}},
|
"Example: gimbal nudge yaw -5"}},
|
||||||
{"gimbal home [y|p]",
|
{"gimbal home [y|p]",
|
||||||
"Run the endstop-finding home sequence (both axes, or one).", {
|
"Run the endstop-finding home sequence (both axes, or one).", {
|
||||||
|
|
@ -93,25 +92,6 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"Toggle verbatim wire-trace categories.", {
|
"Toggle verbatim wire-trace categories.", {
|
||||||
"Example: trace serial on / trace off"}},
|
"Example: trace serial on / trace off"}},
|
||||||
}},
|
}},
|
||||||
{"Display", "Terminal dashboard keys (TUI only).", {
|
|
||||||
{"? / g / i / c",
|
|
||||||
"Toggle the Help, Gimbal, Sensors and Camera overlays.", {
|
|
||||||
"An overlay takes over the body, so it gets the full terminal height.",
|
|
||||||
"Esc closes it; with no overlay open, Esc cancels a running procedure."}},
|
|
||||||
{"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. The LOG title shows 'scrollback' while it is frozen."}},
|
|
||||||
{"Home/End",
|
|
||||||
"Jump to the top/bottom; on the log, End resumes following the tail.", {}},
|
|
||||||
{"p",
|
|
||||||
"Freeze the 10 Hz repaint so terminal text selection survives.", {
|
|
||||||
"Any keypress still repaints, so scrolling while paused drops a",
|
|
||||||
"selection - pause is for copying, not for reading scrollback."}},
|
|
||||||
}},
|
|
||||||
{"Session", "Help and exit.", {
|
{"Session", "Help and exit.", {
|
||||||
{"refresh",
|
{"refresh",
|
||||||
"Re-read live device state (TUI: press 'r').", {
|
"Re-read live device state (TUI: press 'r').", {
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
#include "fgc/SingleInstance.h"
|
|
||||||
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <string>
|
|
||||||
#include <system_error>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <sys/file.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// PID currently recorded in an already-locked file, or 0 if it can't be read.
|
|
||||||
// Best-effort: the message is nicer with a PID but correctness doesn't need it.
|
|
||||||
int readPid(int fd) {
|
|
||||||
char buf[32] = {};
|
|
||||||
ssize_t n = ::pread(fd, buf, sizeof(buf) - 1, 0);
|
|
||||||
if (n <= 0) return 0;
|
|
||||||
buf[n] = '\0';
|
|
||||||
return static_cast<int>(std::strtol(buf, nullptr, 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
std::string SingleInstance::defaultLockPath() {
|
|
||||||
const std::string uid = std::to_string(::getuid());
|
|
||||||
|
|
||||||
// Deliberately derived from the uid rather than read from $XDG_RUNTIME_DIR:
|
|
||||||
// that variable is set in an interactive ssh shell but *not* in a
|
|
||||||
// non-interactive `ssh host cmd` one, so reading it would give the two
|
|
||||||
// launch paths two different lockfiles - and a lock the launchers disagree
|
|
||||||
// about is no lock at all. The directory itself is there either way.
|
|
||||||
std::error_code ec;
|
|
||||||
const std::string run_dir = "/run/user/" + uid;
|
|
||||||
if (std::filesystem::is_directory(run_dir, ec))
|
|
||||||
return run_dir + "/fire_gimbal_control.lock";
|
|
||||||
|
|
||||||
return "/tmp/fire_gimbal_control-" + uid + ".lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
SingleInstance SingleInstance::acquire(const std::string& path) {
|
|
||||||
SingleInstance self;
|
|
||||||
self.path_ = path;
|
|
||||||
|
|
||||||
// O_NOFOLLOW: the /tmp fallback path is world-writable, so refuse to follow a
|
|
||||||
// symlink another user could have planted there. O_CLOEXEC keeps the lock
|
|
||||||
// from leaking into anything we exec.
|
|
||||||
int fd = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
|
|
||||||
if (fd < 0) {
|
|
||||||
self.error_ = "cannot open lockfile " + path + ": " + std::strerror(errno);
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (::flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
|
||||||
if (errno == EWOULDBLOCK) {
|
|
||||||
self.holder_pid_ = readPid(fd);
|
|
||||||
self.error_ = "another instance is already running";
|
|
||||||
if (self.holder_pid_ > 0) self.error_ += " (PID " + std::to_string(self.holder_pid_) + ")";
|
|
||||||
self.error_ += "; attach to it with: scripts/fgc attach";
|
|
||||||
} else {
|
|
||||||
self.error_ = "cannot lock " + path + ": " + std::strerror(errno);
|
|
||||||
}
|
|
||||||
::close(fd);
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record our PID for the benefit of the next launch's error message and of
|
|
||||||
// `fgc status`. Failures here are not fatal - the lock itself is what counts.
|
|
||||||
const std::string pid = std::to_string(::getpid()) + "\n";
|
|
||||||
if (::ftruncate(fd, 0) == 0) {
|
|
||||||
ssize_t ignored = ::pwrite(fd, pid.data(), pid.size(), 0);
|
|
||||||
(void)ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.fd_ = fd;
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
SingleInstance::~SingleInstance() { release(); }
|
|
||||||
|
|
||||||
SingleInstance::SingleInstance(SingleInstance&& other) noexcept
|
|
||||||
: fd_(std::exchange(other.fd_, -1)),
|
|
||||||
holder_pid_(other.holder_pid_),
|
|
||||||
path_(std::move(other.path_)),
|
|
||||||
error_(std::move(other.error_)) {}
|
|
||||||
|
|
||||||
SingleInstance& SingleInstance::operator=(SingleInstance&& other) noexcept {
|
|
||||||
if (this != &other) {
|
|
||||||
release();
|
|
||||||
fd_ = std::exchange(other.fd_, -1);
|
|
||||||
holder_pid_ = other.holder_pid_;
|
|
||||||
path_ = std::move(other.path_);
|
|
||||||
error_ = std::move(other.error_);
|
|
||||||
}
|
|
||||||
return *this;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SingleInstance::release() {
|
|
||||||
if (fd_ < 0) return;
|
|
||||||
// Closing the descriptor releases the flock. The file is left in place;
|
|
||||||
// recreating it on every launch would race with a concurrent acquire.
|
|
||||||
::close(fd_);
|
|
||||||
fd_ = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
#include "fgc/ui/Scroll.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
int scrollMax(int content_h, int view_h) {
|
|
||||||
// An unmeasured view (first frame after an overlay opens or the terminal
|
|
||||||
// resizes) pins to the top rather than guessing: the next repaint is 100ms
|
|
||||||
// away and a guess would visibly jump.
|
|
||||||
if (view_h <= 0) return 0;
|
|
||||||
return std::max(0, content_h - view_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
int scrollClamp(int top, int content_h, int view_h) {
|
|
||||||
return std::clamp(top, 0, scrollMax(content_h, view_h));
|
|
||||||
}
|
|
||||||
|
|
||||||
int scrollBy(int top, int delta, int content_h, int view_h) {
|
|
||||||
return scrollClamp(top + delta, content_h, view_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
int scrollPage(int top, int dir, int content_h, int view_h) {
|
|
||||||
const int step = std::max(1, view_h - 2);
|
|
||||||
return scrollClamp(top + dir * step, content_h, view_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::size_t gridRowsPerColumn(std::size_t item_count, std::size_t max_cols, std::size_t min_rows) {
|
|
||||||
max_cols = std::max<std::size_t>(max_cols, 1);
|
|
||||||
min_rows = std::max<std::size_t>(min_rows, 1);
|
|
||||||
const std::size_t needed = (item_count + max_cols - 1) / max_cols; // ceil
|
|
||||||
return std::max(min_rows, needed);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
276
src/ui/TuiUi.cpp
276
src/ui/TuiUi.cpp
|
|
@ -3,10 +3,7 @@
|
||||||
#include "fgc/DumpParser.h"
|
#include "fgc/DumpParser.h"
|
||||||
#include "fgc/HelpText.h"
|
#include "fgc/HelpText.h"
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
#include "fgc/ui/Scroll.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <array>
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
@ -17,9 +14,6 @@
|
||||||
#include <ftxui/component/event.hpp>
|
#include <ftxui/component/event.hpp>
|
||||||
#include <ftxui/component/screen_interactive.hpp>
|
#include <ftxui/component/screen_interactive.hpp>
|
||||||
#include <ftxui/dom/elements.hpp>
|
#include <ftxui/dom/elements.hpp>
|
||||||
#include <ftxui/dom/node.hpp>
|
|
||||||
#include <ftxui/dom/requirement.hpp>
|
|
||||||
#include <ftxui/screen/box.hpp>
|
|
||||||
#include <ftxui/screen/color.hpp>
|
#include <ftxui/screen/color.hpp>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
@ -45,88 +39,6 @@ Element panel(const std::string& title, Color title_color, Element body) {
|
||||||
return window(text(" " + title + " ") | bold | color(title_color), std::move(body)) | flex;
|
return window(text(" " + title + " ") | bold | color(title_color), std::move(body)) | flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Scrollable panes -------------------------------------------------------
|
|
||||||
//
|
|
||||||
// `yframe` clips its child to the available box and scrolls to reveal whichever
|
|
||||||
// element carries the "selected box". Nothing sets one by default, so a bare
|
|
||||||
// `vbox(rows) | yframe` renders the TOP of the content and silently drops the
|
|
||||||
// rest - which is what used to make long waypoint lists and register dumps
|
|
||||||
// unreachable.
|
|
||||||
//
|
|
||||||
// The anchor has to be applied DIRECTLY to the frame's child: focusPosition
|
|
||||||
// leaves requirement_.selection at NORMAL, and vbox/hbox only propagate a
|
|
||||||
// child's selected box when its selection is strictly greater than their own
|
|
||||||
// (which starts at NORMAL). Only focus(), which sets FOCUSED, would survive
|
|
||||||
// being buried inside a row - and it is unusable here because the rows have
|
|
||||||
// wildly different heights (a 3-line axis row, a 1-line separator, the whole
|
|
||||||
// scan grid as one ~20-line element), so a per-row cursor would scroll by 1, 3
|
|
||||||
// or 40 lines a keypress.
|
|
||||||
// Capture a child's REQUESTED height. reflect() cannot do this: its Render
|
|
||||||
// intersects the box with the screen stencil, so inside a frame it hands back
|
|
||||||
// the visible height, and content/view would always compare equal.
|
|
||||||
Decorator reflectHeight(int& out) {
|
|
||||||
class Impl : public Node {
|
|
||||||
public:
|
|
||||||
Impl(Element child, int& out) : Node(unpack(std::move(child))), out_(out) {}
|
|
||||||
void ComputeRequirement() override {
|
|
||||||
Node::ComputeRequirement();
|
|
||||||
requirement_ = children_[0]->requirement();
|
|
||||||
out_ = requirement_.min_y;
|
|
||||||
}
|
|
||||||
void SetBox(Box box) override {
|
|
||||||
Node::SetBox(box);
|
|
||||||
children_[0]->SetBox(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
int& out_;
|
|
||||||
};
|
|
||||||
return [&out](Element child) { return std::make_shared<Impl>(std::move(child), out); };
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScrollState {
|
|
||||||
int top = 0; // first body LINE we want visible
|
|
||||||
bool follow = false; // log only: pin to the newest line instead
|
|
||||||
|
|
||||||
// Measured by the previous frame: the body's full requested height, and the
|
|
||||||
// visible height of the pane it is framed into.
|
|
||||||
int content_h = 0;
|
|
||||||
Box view{};
|
|
||||||
|
|
||||||
int contentH() const { return content_h; }
|
|
||||||
int viewH() const { return view.y_max - view.y_min + 1; }
|
|
||||||
bool scrollable() const { return scrollMax(contentH(), viewH()) > 0; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 -
|
|
||||||
// view/2 + focused/2, then clamped), so the anchor is pushed half a screen down
|
|
||||||
// to cancel that and land on `top` exactly.
|
|
||||||
Element scrollableBody(Element body, ScrollState& st) {
|
|
||||||
st.top = scrollClamp(st.top, st.contentH(), st.viewH());
|
|
||||||
Decorator anchor = st.follow
|
|
||||||
? focusPositionRelative(0.f, 1.f)
|
|
||||||
: focusPosition(0, st.top + st.viewH() / 2);
|
|
||||||
return std::move(body) | reflectHeight(st.content_h) | anchor | vscroll_indicator | yframe |
|
|
||||||
reflect(st.view);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay window title. The scroll keys are advertised only while there is
|
|
||||||
// something off-screen, so the hint doubles as the "there is more below" cue on
|
|
||||||
// a pane that happens to fit.
|
|
||||||
Element overlayTitle(const std::string& name, const std::string& keys, const ScrollState& st,
|
|
||||||
Color c = Color::Cyan, bool arrows_scroll = true) {
|
|
||||||
std::string hint = keys;
|
|
||||||
if (st.scrollable())
|
|
||||||
hint += arrows_scroll ? " \xE2\x86\x91\xE2\x86\x93/PgUp/PgDn:scroll" : " PgUp/PgDn:scroll";
|
|
||||||
return text(" " + name + " (" + hint + ") ") | bold | color(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
Element cameraDetailTitle(const ScrollState& st) {
|
|
||||||
return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "key" + label pair for the nano-style bottom bar.
|
// "key" + label pair for the nano-style bottom bar.
|
||||||
Element keyHint(const std::string& key, const std::string& label) {
|
Element keyHint(const std::string& key, const std::string& label) {
|
||||||
return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")});
|
return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")});
|
||||||
|
|
@ -229,7 +141,7 @@ Element cameraPanel(const CaptureView& c) {
|
||||||
// Expanded camera view ('c'): the full camera system — identity, live sensor
|
// Expanded camera view ('c'): the full camera system — identity, live sensor
|
||||||
// telemetry, imaging config, acquisition health, encoding/output — with the
|
// telemetry, imaging config, acquisition health, encoding/output — with the
|
||||||
// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
|
// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
|
||||||
Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
Element cameraDetailPanel(const CaptureView& c) {
|
||||||
auto num = [](double v, int dec) {
|
auto num = [](double v, int dec) {
|
||||||
char b[32];
|
char b[32];
|
||||||
std::snprintf(b, sizeof(b), "%.*f", dec, v);
|
std::snprintf(b, sizeof(b), "%.*f", dec, v);
|
||||||
|
|
@ -347,23 +259,17 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)}));
|
rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)}));
|
||||||
rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup "
|
rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup "
|
||||||
"'Loaded config:' log line).") | dim);
|
"'Loaded config:' log line).") | dim);
|
||||||
return window(cameraDetailTitle(st),
|
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
||||||
scrollableBody(vbox(std::move(rows)), st));
|
vbox(std::move(rows)) | yframe);
|
||||||
}
|
}
|
||||||
if (c.scan_grid.empty()) {
|
if (c.scan_grid.empty()) {
|
||||||
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
||||||
return window(cameraDetailTitle(st),
|
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
||||||
scrollableBody(vbox(std::move(rows)), st));
|
vbox(std::move(rows)) | yframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
|
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
|
||||||
// Cap the COLUMN count rather than the row count: past the cap the grid grows
|
constexpr size_t kRows = 18; // rows per column before wrapping
|
||||||
// downward, where the pane's vertical scrolling can reach it. Wrapping at a
|
|
||||||
// fixed 18 rows grew it rightwards instead, off the edge of a screen that
|
|
||||||
// yframe does not clip and no key can pan.
|
|
||||||
constexpr size_t kMaxCols = 3; // ~66 columns wide: fits an 80-column terminal
|
|
||||||
constexpr size_t kMinRows = 18; // short grids keep the original compact look
|
|
||||||
const size_t kRows = gridRowsPerColumn(c.scan_grid.size(), kMaxCols, kMinRows);
|
|
||||||
std::vector<Element> cols;
|
std::vector<Element> cols;
|
||||||
std::vector<Element> col;
|
std::vector<Element> col;
|
||||||
auto flushColumn = [&] {
|
auto flushColumn = [&] {
|
||||||
|
|
@ -389,7 +295,8 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
rows.push_back(hbox({text(" # ") | dim,
|
rows.push_back(hbox({text(" # ") | dim,
|
||||||
text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim}));
|
text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim}));
|
||||||
rows.push_back(hbox(std::move(cols)));
|
rows.push_back(hbox(std::move(cols)));
|
||||||
return window(cameraDetailTitle(st), scrollableBody(vbox(std::move(rows)), st));
|
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
||||||
|
vbox(std::move(rows)) | yframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
Element connPanel(const ConnView& v) {
|
Element connPanel(const ConnView& v) {
|
||||||
|
|
@ -406,7 +313,7 @@ Element connPanel(const ConnView& v) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Element logPanel(const std::vector<LogLine>& lines, ScrollState& st) {
|
Element logPanel(const std::vector<LogLine>& lines) {
|
||||||
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;
|
||||||
|
|
@ -420,15 +327,8 @@ 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.
|
return window(text(" LOG ") | bold | color(Color::GrayLight),
|
||||||
Element label = text(" LOG ") | bold | color(Color::GrayLight);
|
vbox(std::move(rows)) | focusPositionRelative(0, 1) | yframe);
|
||||||
Element title = label;
|
|
||||||
if (!st.follow)
|
|
||||||
title = hbox({label, text("scrollback ") | color(Color::Yellow) | bold,
|
|
||||||
text("(End:follow) ") | dim});
|
|
||||||
else if (st.scrollable())
|
|
||||||
title = hbox({label, text("(PgUp:scrollback) ") | dim});
|
|
||||||
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
|
||||||
|
|
@ -460,7 +360,7 @@ Element activityPanel(const ActivityView& a) {
|
||||||
// Inline help pane (toggled with '?'). Lists every command section; the
|
// Inline help pane (toggled with '?'). Lists every command section; the
|
||||||
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
|
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
|
||||||
// section additionally renders the last captured firmware DUMP block.
|
// section additionally renders the last captured firmware DUMP block.
|
||||||
Element helpPanel(int sel, const DumpView& dump, ScrollState& st) {
|
Element helpPanel(int sel, const DumpView& dump) {
|
||||||
const auto& cat = helpCatalog();
|
const auto& cat = helpCatalog();
|
||||||
std::vector<Element> rows;
|
std::vector<Element> rows;
|
||||||
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
|
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
|
||||||
|
|
@ -495,9 +395,8 @@ Element helpPanel(int sel, const DumpView& dump, ScrollState& st) {
|
||||||
}
|
}
|
||||||
rows.push_back(text(""));
|
rows.push_back(text(""));
|
||||||
}
|
}
|
||||||
return window(overlayTitle("HELP", "?:close Up/Down:section", st, Color::Cyan,
|
return window(text(" HELP (?:close Up/Down:section) ") | bold | color(Color::Cyan),
|
||||||
/*arrows_scroll=*/false),
|
vbox(std::move(rows)) | yframe);
|
||||||
scrollableBody(vbox(std::move(rows)), st));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aligned "label: value" row for the detail view.
|
// Aligned "label: value" row for the detail view.
|
||||||
|
|
@ -590,7 +489,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
|
||||||
// (incl. RAMP_STAT, the last one) is visible without scrolling.
|
// (incl. RAMP_STAT, the last one) is visible without scrolling.
|
||||||
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
|
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
|
||||||
char letter, bool calib_has, const CalibAxisView& cal,
|
char letter, bool calib_has, const CalibAxisView& cal,
|
||||||
const DiagAxisView* diag, ScrollState& st) {
|
const DiagAxisView* diag) {
|
||||||
auto fmt = [](const char* f, double v) {
|
auto fmt = [](const char* f, double v) {
|
||||||
char b[32];
|
char b[32];
|
||||||
std::snprintf(b, sizeof(b), f, v);
|
std::snprintf(b, sizeof(b), f, v);
|
||||||
|
|
@ -647,15 +546,13 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
|
||||||
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim);
|
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the
|
return panel(title, Color::Cyan, vbox(std::move(col)) | yframe);
|
||||||
// whole hbox instead would scroll the titles away with the content.
|
|
||||||
return panel(title, Color::Cyan, scrollableBody(vbox(std::move(col)), st));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen gimbal view (toggled with 'g'): one column per axis, each with
|
// Full-screen gimbal view (toggled with 'g'): one column per axis, each with
|
||||||
// live telemetry above its decoded firmware register dump + last calibration.
|
// live telemetry above its decoded firmware register dump + last calibration.
|
||||||
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
|
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
|
||||||
const DiagResultView& diag, std::array<ScrollState, 2>& st) {
|
const DiagResultView& diag) {
|
||||||
DumpData d = parseDump(dump.text);
|
DumpData d = parseDump(dump.text);
|
||||||
auto diagFor = [&](char axis) -> const DiagAxisView* {
|
auto diagFor = [&](char axis) -> const DiagAxisView* {
|
||||||
for (const auto& a : diag.axes)
|
for (const auto& a : diag.axes)
|
||||||
|
|
@ -683,12 +580,11 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
|
||||||
});
|
});
|
||||||
|
|
||||||
std::vector<Element> cols;
|
std::vector<Element> cols;
|
||||||
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'), st[0]));
|
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y')));
|
||||||
if (g.pitch_present)
|
if (g.pitch_present)
|
||||||
cols.push_back(
|
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P')));
|
||||||
axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'), st[1]));
|
|
||||||
|
|
||||||
return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]),
|
return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan),
|
||||||
vbox({header, separator(), hbox(std::move(cols)) | flex}));
|
vbox({header, separator(), hbox(std::move(cols)) | flex}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,7 +592,7 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
|
||||||
// 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.
|
||||||
Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st) {
|
Element sensorsDetailPanel(const ImuView& v, const EnvView& e) {
|
||||||
// Fixed-width, right-aligned to 2 decimals. The constant width keeps the sign
|
// Fixed-width, right-aligned to 2 decimals. The constant width keeps the sign
|
||||||
// column and decimal point from jumping as values cross zero or change digit
|
// column and decimal point from jumping as values cross zero or change digit
|
||||||
// count, so the readout stays steady instead of flickering. The width is kept
|
// count, so the readout stays steady instead of flickering. The width is kept
|
||||||
|
|
@ -845,11 +741,11 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
|
||||||
? (text(" " + env_name + " live ") | color(Color::Green) | bold)
|
? (text(" " + env_name + " live ") | color(Color::Green) | bold)
|
||||||
: (text(" " + env_name + " no reading ") | color(Color::Red) |
|
: (text(" " + env_name + " no reading ") | color(Color::Red) |
|
||||||
bold));
|
bold));
|
||||||
// Scrolled so a long body (MTi channels + config + ambient) stays reachable on
|
// yframe so a long body (MTi channels + config + ambient) stays reachable on a
|
||||||
// a short terminal instead of being silently cut off.
|
// short terminal instead of being silently cut off, as the camera view does.
|
||||||
return window(overlayTitle("SENSORS", "i/Esc:close r:refresh config", st, Color::Magenta),
|
return window(text(" SENSORS (i/Esc:close r:refresh config) ") | bold | color(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}));
|
vbox(std::move(body)) | yframe | flex}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
@ -881,10 +777,7 @@ void TuiUi::stop() {
|
||||||
void TuiUi::pushLog(LogLevel level, const std::string& line) {
|
void TuiUi::pushLog(LogLevel level, const std::string& line) {
|
||||||
std::lock_guard<std::mutex> lock(log_mutex_);
|
std::lock_guard<std::mutex> lock(log_mutex_);
|
||||||
log_.push_back({level, line});
|
log_.push_back({level, line});
|
||||||
while (log_.size() > kLogCap) {
|
while (log_.size() > kLogCap) log_.pop_front();
|
||||||
log_.pop_front();
|
|
||||||
++log_dropped_;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiUi::refreshLoop() {
|
void TuiUi::refreshLoop() {
|
||||||
|
|
@ -914,30 +807,13 @@ void TuiUi::uiLoop() {
|
||||||
bool gimbal_dump_requested = false; // auto-pull a dump the first time
|
bool gimbal_dump_requested = false; // auto-pull a dump the first time
|
||||||
bool calib_prompt = false; // a yes/no calib-save question is showing
|
bool calib_prompt = false; // a yes/no calib-save question is showing
|
||||||
|
|
||||||
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda
|
|
||||||
// and CatchEvent below both run on this thread), so unlike screen_/paused_
|
|
||||||
// these need no synchronisation and stay locals rather than TuiUi members.
|
|
||||||
ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll;
|
|
||||||
std::array<ScrollState, 2> gimbal_scroll{}; // YAW, PITCH
|
|
||||||
log_scroll.follow = true; // the log tracks the tail until told otherwise
|
|
||||||
size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered
|
|
||||||
|
|
||||||
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
|
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
|
||||||
|
|
||||||
auto renderer = Renderer(input, [&] {
|
auto renderer = Renderer(input, [&] {
|
||||||
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
|
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
|
||||||
size_t log_dropped_now = 0;
|
|
||||||
{
|
{
|
||||||
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_;
|
|
||||||
}
|
|
||||||
// Evicting from the front of the ring buffer slides every line up under a
|
|
||||||
// frozen viewport, so a busy log would drag the view the operator parked.
|
|
||||||
// Absorb the shift; the clamp in scrollableBody handles running off the top.
|
|
||||||
if (!log_scroll.follow && log_dropped_now != log_dropped_at_freeze) {
|
|
||||||
log_scroll.top -= static_cast<int>(log_dropped_now - log_dropped_at_freeze);
|
|
||||||
log_dropped_at_freeze = log_dropped_now;
|
|
||||||
}
|
}
|
||||||
calib_prompt = !s.activity.prompt.empty();
|
calib_prompt = !s.activity.prompt.empty();
|
||||||
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
|
||||||
|
|
@ -957,20 +833,9 @@ void TuiUi::uiLoop() {
|
||||||
Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)});
|
Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)});
|
||||||
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)});
|
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)});
|
||||||
|
|
||||||
const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
|
|
||||||
Element bottom;
|
Element bottom;
|
||||||
if (command_mode) {
|
if (command_mode) {
|
||||||
bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border;
|
bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border;
|
||||||
} else if (overlay != Overlay::None) {
|
|
||||||
// The nudge arrows are inert while an overlay owns the body, so the bar
|
|
||||||
// advertises what the keys actually do here instead.
|
|
||||||
bottom = hbox({
|
|
||||||
(overlay == Overlay::Help ? keyHint(kArrowsV, "Section")
|
|
||||||
: keyHint(kArrowsV, "Scroll")),
|
|
||||||
keyHint("PgUp/Dn", "Page"), keyHint("Home/End", "Ends"),
|
|
||||||
keyHint("p", "Pause"), keyHint(":", "Cmd"), filler(),
|
|
||||||
keyHint("Esc", "Close"), keyHint("q", "Quit"),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
bottom = hbox({
|
bottom = hbox({
|
||||||
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
|
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
|
||||||
|
|
@ -986,22 +851,20 @@ void TuiUi::uiLoop() {
|
||||||
// gimbal register dump overflows the small log-sized area and clips.
|
// gimbal register dump overflows the small log-sized area and clips.
|
||||||
switch (overlay) {
|
switch (overlay) {
|
||||||
case Overlay::Help:
|
case Overlay::Help:
|
||||||
return vbox({header, separator(), helpPanel(help_sel, s.dump, help_scroll) | flex, bottom});
|
return vbox({header, separator(), helpPanel(help_sel, s.dump) | flex, bottom});
|
||||||
case Overlay::Gimbal:
|
case Overlay::Gimbal:
|
||||||
return vbox({header, separator(),
|
return vbox({header, separator(),
|
||||||
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag, gimbal_scroll) |
|
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag) | flex, bottom});
|
||||||
flex,
|
|
||||||
bottom});
|
|
||||||
case Overlay::Sensors:
|
case Overlay::Sensors:
|
||||||
return vbox({header, separator(),
|
return vbox({header, separator(),
|
||||||
sensorsDetailPanel(s.imu, s.env, sensors_scroll) | flex, bottom});
|
sensorsDetailPanel(s.imu, s.env) | flex, bottom});
|
||||||
case Overlay::Cameras:
|
case Overlay::Cameras:
|
||||||
return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
|
return vbox({header, separator(), cameraDetailPanel(s.capture) | 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) | 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));
|
col.push_back(activityPanel(s.activity));
|
||||||
col.push_back(bottom);
|
col.push_back(bottom);
|
||||||
|
|
@ -1010,24 +873,6 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply a scroll key to one pane. `arrows` is false where Up/Down already mean
|
|
||||||
// something else (help section nav; gimbal nudge on the dashboard), leaving
|
|
||||||
// PgUp/PgDn/Home/End as the only scroll keys there.
|
|
||||||
auto scrollKeys = [](ScrollState& st, const Event& e, bool arrows) {
|
|
||||||
const int ch = st.contentH(), vh = st.viewH();
|
|
||||||
if (arrows && (e == Event::ArrowDown)) { st.top = scrollBy(st.top, 1, ch, vh); return true; }
|
|
||||||
if (arrows && (e == Event::ArrowUp)) { st.top = scrollBy(st.top, -1, ch, vh); return true; }
|
|
||||||
if (e == Event::PageDown) { st.top = scrollPage(st.top, 1, ch, vh); return true; }
|
|
||||||
if (e == Event::PageUp) { st.top = scrollPage(st.top, -1, ch, vh); return true; }
|
|
||||||
if (e == Event::Home) { st.top = 0; return true; }
|
|
||||||
if (e == Event::End) { st.top = scrollMax(ch, vh); return true; }
|
|
||||||
if (arrows && e.is_character()) {
|
|
||||||
if (e.character() == "j") { st.top = scrollBy(st.top, 1, ch, vh); return true; }
|
|
||||||
if (e.character() == "k") { st.top = scrollBy(st.top, -1, ch, vh); return true; }
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
auto root = CatchEvent(renderer, [&](Event e) {
|
auto root = CatchEvent(renderer, [&](Event e) {
|
||||||
if (command_mode) {
|
if (command_mode) {
|
||||||
if (e == Event::Return) {
|
if (e == Event::Return) {
|
||||||
|
|
@ -1045,61 +890,14 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
const int n = static_cast<int>(helpCatalog().size());
|
const int n = static_cast<int>(helpCatalog().size());
|
||||||
if (overlay == Overlay::Help) { // help pane navigation
|
if (overlay == Overlay::Help) { // help pane navigation
|
||||||
// Selecting a different section replaces the body wholesale, so the
|
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; return true; }
|
||||||
// old scroll position would be meaningless - start at the top.
|
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; }
|
||||||
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
|
|
||||||
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
|
|
||||||
}
|
|
||||||
// Scroll the overlay that owns the body. Arrows are free here except in
|
|
||||||
// Help, where the two cases above already claimed them for section nav.
|
|
||||||
if (overlay != Overlay::None) {
|
|
||||||
const bool arrows = (overlay != Overlay::Help);
|
|
||||||
switch (overlay) {
|
|
||||||
case Overlay::Cameras:
|
|
||||||
if (scrollKeys(cam_scroll, e, arrows)) return true;
|
|
||||||
break;
|
|
||||||
case Overlay::Sensors:
|
|
||||||
if (scrollKeys(sensors_scroll, e, arrows)) return true;
|
|
||||||
break;
|
|
||||||
case Overlay::Help:
|
|
||||||
if (scrollKeys(help_scroll, e, arrows)) return true;
|
|
||||||
break;
|
|
||||||
case Overlay::Gimbal: {
|
|
||||||
// Both axis columns move together: they are read side by side,
|
|
||||||
// and each clamps against its own measured height.
|
|
||||||
const bool a = scrollKeys(gimbal_scroll[0], e, arrows);
|
|
||||||
const bool b = scrollKeys(gimbal_scroll[1], e, arrows);
|
|
||||||
if (a || b) return true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Overlay::None: break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
|
||||||
// PgUp/PgDn/Home/End only; End resumes following the tail.
|
|
||||||
if (overlay == Overlay::None) {
|
|
||||||
if (e == Event::End) {
|
|
||||||
log_scroll.follow = true;
|
|
||||||
log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e == Event::PageUp || e == Event::PageDown || e == Event::Home) {
|
|
||||||
if (log_scroll.follow) {
|
|
||||||
// Entering scrollback: freeze where the tail currently sits, and
|
|
||||||
// start tracking evictions from this point.
|
|
||||||
log_scroll.follow = false;
|
|
||||||
log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH());
|
|
||||||
std::lock_guard<std::mutex> lock(log_mutex_);
|
|
||||||
log_dropped_at_freeze = log_dropped_;
|
|
||||||
}
|
|
||||||
return scrollKeys(log_scroll, e, /*arrows=*/false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Arrow keys nudge the gimbal in steps (only when no overlay is open):
|
// Arrow keys nudge the gimbal in steps (only when no overlay is open):
|
||||||
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel.
|
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel.
|
||||||
if (overlay == Overlay::None && sink_) {
|
if (overlay == Overlay::None && sink_) {
|
||||||
|
|
@ -1117,7 +915,6 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
if (c == "?") {
|
if (c == "?") {
|
||||||
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help;
|
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help;
|
||||||
if (overlay == Overlay::Help) help_scroll = {};
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "g") {
|
if (c == "g") {
|
||||||
|
|
@ -1125,7 +922,6 @@ void TuiUi::uiLoop() {
|
||||||
overlay = Overlay::None;
|
overlay = Overlay::None;
|
||||||
} else {
|
} else {
|
||||||
overlay = Overlay::Gimbal;
|
overlay = Overlay::Gimbal;
|
||||||
gimbal_scroll = {};
|
|
||||||
if (!gimbal_dump_requested) { // auto-pull a dump the first time
|
if (!gimbal_dump_requested) { // auto-pull a dump the first time
|
||||||
if (sink_) sink_("gimbal dump");
|
if (sink_) sink_("gimbal dump");
|
||||||
gimbal_dump_requested = true;
|
gimbal_dump_requested = true;
|
||||||
|
|
@ -1139,12 +935,10 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
if (c == "i") {
|
if (c == "i") {
|
||||||
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
||||||
if (overlay == Overlay::Sensors) sensors_scroll = {};
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "c") {
|
if (c == "c") {
|
||||||
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
||||||
if (overlay == Overlay::Cameras) cam_scroll = {};
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
|
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
|
||||||
|
|
@ -1152,8 +946,8 @@ void TuiUi::uiLoop() {
|
||||||
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) { // vim-style 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; 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; return true; }
|
||||||
}
|
}
|
||||||
if (c == "q") { if (sink_) sink_("exit"); return true; }
|
if (c == "q") { if (sink_) sink_("exit"); return true; }
|
||||||
if (c == "s") { if (sink_) sink_("start"); return true; }
|
if (c == "s") { if (sink_) sink_("start"); return true; }
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ unset(CMAKE_POLICY_VERSION_MINIMUM)
|
||||||
add_executable(fgc_tests
|
add_executable(fgc_tests
|
||||||
doctest_main.cpp
|
doctest_main.cpp
|
||||||
test_paths.cpp
|
test_paths.cpp
|
||||||
test_singleinstance.cpp
|
|
||||||
test_config.cpp
|
test_config.cpp
|
||||||
test_telemetry.cpp
|
test_telemetry.cpp
|
||||||
test_command.cpp
|
test_command.cpp
|
||||||
|
|
@ -29,7 +28,6 @@ add_executable(fgc_tests
|
||||||
test_geometry.cpp
|
test_geometry.cpp
|
||||||
test_scangrid.cpp
|
test_scangrid.cpp
|
||||||
test_uisnapshot.cpp
|
test_uisnapshot.cpp
|
||||||
test_scroll.cpp
|
|
||||||
test_mtiprotocol.cpp
|
test_mtiprotocol.cpp
|
||||||
test_sht41.cpp
|
test_sht41.cpp
|
||||||
test_imagequality.cpp
|
test_imagequality.cpp
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
#include <doctest/doctest.h>
|
|
||||||
|
|
||||||
#include "fgc/ui/Scroll.h"
|
|
||||||
|
|
||||||
#include <initializer_list>
|
|
||||||
|
|
||||||
using namespace fgc;
|
|
||||||
|
|
||||||
TEST_CASE("scrollMax is zero when the content fits") {
|
|
||||||
CHECK(scrollMax(10, 20) == 0);
|
|
||||||
CHECK(scrollMax(20, 20) == 0);
|
|
||||||
CHECK(scrollMax(21, 20) == 1);
|
|
||||||
CHECK(scrollMax(100, 20) == 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("an unmeasured view pins to the top") {
|
|
||||||
// First frame after an overlay opens: reflect() has not run yet, so every
|
|
||||||
// height is 0. Scrolling must be a no-op rather than a guess.
|
|
||||||
CHECK(scrollMax(500, 0) == 0);
|
|
||||||
CHECK(scrollClamp(42, 500, 0) == 0);
|
|
||||||
CHECK(scrollBy(0, 1, 500, 0) == 0);
|
|
||||||
CHECK(scrollPage(0, 1, 500, 0) == 0);
|
|
||||||
CHECK(scrollMax(500, -3) == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("scrollClamp pulls a stale position back into range") {
|
|
||||||
// The pane was scrolled to the bottom of a long dump, then the dump was
|
|
||||||
// replaced by a shorter one: the view must not stay parked past the end.
|
|
||||||
CHECK(scrollClamp(80, 100, 20) == 80);
|
|
||||||
CHECK(scrollClamp(80, 30, 20) == 10);
|
|
||||||
CHECK(scrollClamp(80, 10, 20) == 0);
|
|
||||||
CHECK(scrollClamp(-5, 100, 20) == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("scrollBy steps one line and stops at the ends") {
|
|
||||||
CHECK(scrollBy(0, 1, 100, 20) == 1);
|
|
||||||
CHECK(scrollBy(5, -1, 100, 20) == 4);
|
|
||||||
CHECK(scrollBy(0, -1, 100, 20) == 0); // already at the top
|
|
||||||
CHECK(scrollBy(80, 1, 100, 20) == 80); // already at the bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("scrollPage keeps two lines of overlap") {
|
|
||||||
CHECK(scrollPage(0, 1, 100, 20) == 18);
|
|
||||||
CHECK(scrollPage(18, -1, 100, 20) == 0);
|
|
||||||
// PgDn then PgUp returns exactly where it started, away from the ends.
|
|
||||||
CHECK(scrollPage(scrollPage(30, 1, 200, 20), -1, 200, 20) == 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("scrollPage still advances in a one-line pane") {
|
|
||||||
// view_h - 2 would be negative; the step floor keeps the keys usable.
|
|
||||||
CHECK(scrollPage(0, 1, 100, 1) == 1);
|
|
||||||
CHECK(scrollPage(0, 1, 100, 2) == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("gridRowsPerColumn keeps short grids compact") {
|
|
||||||
// Under the column cap the grid looks exactly as it did before: 18 per column.
|
|
||||||
CHECK(gridRowsPerColumn(0, 3, 18) == 18);
|
|
||||||
CHECK(gridRowsPerColumn(1, 3, 18) == 18);
|
|
||||||
CHECK(gridRowsPerColumn(30, 3, 18) == 18);
|
|
||||||
CHECK(gridRowsPerColumn(54, 3, 18) == 18); // exactly 3 full columns
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("gridRowsPerColumn grows downward past the column cap") {
|
|
||||||
// Past the cap the grid must get taller, not wider, or it runs off the
|
|
||||||
// right edge where vertical scrolling cannot reach it.
|
|
||||||
CHECK(gridRowsPerColumn(55, 3, 18) == 19);
|
|
||||||
CHECK(gridRowsPerColumn(200, 3, 18) == 67);
|
|
||||||
// Never more than max_cols columns.
|
|
||||||
for (std::size_t n : std::initializer_list<std::size_t>{55, 100, 200, 999}) {
|
|
||||||
const std::size_t rows = gridRowsPerColumn(n, 3, 18);
|
|
||||||
CHECK((n + rows - 1) / rows <= 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("gridRowsPerColumn tolerates degenerate parameters") {
|
|
||||||
CHECK(gridRowsPerColumn(10, 0, 18) == 18); // max_cols 0 => treated as 1
|
|
||||||
CHECK(gridRowsPerColumn(100, 0, 1) == 100);
|
|
||||||
CHECK(gridRowsPerColumn(10, 3, 0) >= 1);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
#include <doctest/doctest.h>
|
|
||||||
|
|
||||||
#include "fgc/SingleInstance.h"
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
using namespace fgc;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// A unique lockfile path per test case, cleaned up by the caller.
|
|
||||||
std::string tempLockPath(const char* tag) {
|
|
||||||
return (std::filesystem::temp_directory_path() /
|
|
||||||
("fgc_test_" + std::string(tag) + "_" + std::to_string(::getpid()) + ".lock"))
|
|
||||||
.string();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("acquire succeeds on a fresh path and records our PID") {
|
|
||||||
const std::string path = tempLockPath("fresh");
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
|
|
||||||
auto lock = SingleInstance::acquire(path);
|
|
||||||
REQUIRE(lock.held());
|
|
||||||
CHECK(static_cast<bool>(lock));
|
|
||||||
CHECK(lock.error().empty());
|
|
||||||
CHECK(lock.path() == path);
|
|
||||||
|
|
||||||
std::ifstream in(path);
|
|
||||||
int pid = 0;
|
|
||||||
in >> pid;
|
|
||||||
CHECK(pid == ::getpid());
|
|
||||||
|
|
||||||
lock.release();
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("a second acquire on the same path is refused") {
|
|
||||||
// flock(2) is per open-file-description, not per process, so a second
|
|
||||||
// open+flock from this same test binary contends exactly as another
|
|
||||||
// process would - which is what makes this testable without forking.
|
|
||||||
const std::string path = tempLockPath("contend");
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
|
|
||||||
auto first = SingleInstance::acquire(path);
|
|
||||||
REQUIRE(first.held());
|
|
||||||
|
|
||||||
auto second = SingleInstance::acquire(path);
|
|
||||||
CHECK_FALSE(second.held());
|
|
||||||
CHECK_FALSE(static_cast<bool>(second));
|
|
||||||
CHECK(second.error().find("already running") != std::string::npos);
|
|
||||||
CHECK(second.holderPid() == ::getpid());
|
|
||||||
|
|
||||||
first.release();
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("releasing lets a later acquire through") {
|
|
||||||
const std::string path = tempLockPath("release");
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
|
|
||||||
{
|
|
||||||
auto lock = SingleInstance::acquire(path);
|
|
||||||
REQUIRE(lock.held());
|
|
||||||
} // destructor releases
|
|
||||||
|
|
||||||
auto again = SingleInstance::acquire(path);
|
|
||||||
CHECK(again.held());
|
|
||||||
again.release();
|
|
||||||
CHECK_FALSE(again.held());
|
|
||||||
again.release(); // idempotent
|
|
||||||
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("moving transfers the lock without releasing it") {
|
|
||||||
const std::string path = tempLockPath("move");
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
|
|
||||||
auto lock = SingleInstance::acquire(path);
|
|
||||||
REQUIRE(lock.held());
|
|
||||||
auto moved = std::move(lock);
|
|
||||||
CHECK(moved.held());
|
|
||||||
CHECK_FALSE(lock.held()); // NOLINT(bugprone-use-after-move) - checking the moved-from state
|
|
||||||
|
|
||||||
// Still locked against a fresh attempt.
|
|
||||||
CHECK_FALSE(SingleInstance::acquire(path).held());
|
|
||||||
|
|
||||||
moved.release();
|
|
||||||
std::filesystem::remove(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("acquire reports an unopenable lockfile instead of claiming the lock") {
|
|
||||||
auto lock = SingleInstance::acquire("/nonexistent-dir-fgc-test/x.lock");
|
|
||||||
CHECK_FALSE(lock.held());
|
|
||||||
CHECK(lock.error().find("cannot open") != std::string::npos);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("defaultLockPath is per-uid and independent of the environment") {
|
|
||||||
const std::string uid = std::to_string(::getuid());
|
|
||||||
const std::string run = "/run/user/" + uid;
|
|
||||||
const std::string expected = std::filesystem::is_directory(run)
|
|
||||||
? run + "/fire_gimbal_control.lock"
|
|
||||||
: "/tmp/fire_gimbal_control-" + uid + ".lock";
|
|
||||||
CHECK(SingleInstance::defaultLockPath() == expected);
|
|
||||||
|
|
||||||
// $XDG_RUNTIME_DIR is set in an interactive ssh shell but not in a
|
|
||||||
// non-interactive `ssh host cmd` one. If the path tracked it, those two
|
|
||||||
// launch routes would take different locks and neither would exclude the
|
|
||||||
// other - so the path must not move when the variable does.
|
|
||||||
setenv("XDG_RUNTIME_DIR", "/run/user/4242", 1);
|
|
||||||
CHECK(SingleInstance::defaultLockPath() == expected);
|
|
||||||
unsetenv("XDG_RUNTIME_DIR");
|
|
||||||
CHECK(SingleInstance::defaultLockPath() == expected);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue