Let a yaw-only tower aim with gimbal move, and stop it publishing a made-up pitch
This commit is contained in:
parent
895c2f22fd
commit
f2c61e030e
|
|
@ -225,7 +225,7 @@ press a key that acts.
|
|||
| `:` | type a command, exactly as in the [command list](#everyday-commands) |
|
||||
| `f` | freeze the display, to select and copy text or to page back through the log (the program keeps running) |
|
||||
| `PgUp` `PgDn` | scroll the log, or the open view - the only scroll keys |
|
||||
| arrows | nudge the gimbal by hand, from any view - manual mode only |
|
||||
| arrows | nudge the gimbal by hand, from any view - manual mode only (up/down only on a tower with pitch) |
|
||||
| `Space` | take one picture now (`snapshot`) - manual mode only |
|
||||
| `r` | continuous capture on/off (`capture on` / `capture off`) - manual mode only |
|
||||
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration, a heading setup) |
|
||||
|
|
@ -315,7 +315,7 @@ job. Details, payloads and safety rules: [docs/mqtt-api.md](docs/mqtt-api.md).
|
|||
| `mode auto` / `mode manual` | sweep the scan grid / stop and take the gimbal by hand (it stays homed and powered) |
|
||||
| `mode pan <heading> <span> [<pitch>]` | watch one direction: `mode pan 90 30` covers 75°-105°, a picture every 6° |
|
||||
| `gimbal home` | find the endstops and centre - needed after every power-up |
|
||||
| `gimbal move <yaw>,<pitch>` | point at a **true bearing** (0 = north) and an elevation, in degrees |
|
||||
| `gimbal move <yaw>[,<pitch>]` | point at a **true bearing** (0 = north) and an elevation, in degrees; without a pitch only yaw moves. A yaw-only tower ignores the pitch and refuses pitch-only commands |
|
||||
| `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
|
||||
| `snapshot` | take one picture now (manual mode) |
|
||||
| `capture on` / `capture off` | keep taking pictures where the gimbal is aimed, at the sweep's pace (manual mode; the gimbal can still be nudged) |
|
||||
|
|
|
|||
|
|
@ -195,6 +195,8 @@ pitch_counts_per_deg = 8333.33
|
|||
pitch_center_count = 500000
|
||||
; Elevation above level (+ = up) at the centre. This is NOT 0: the mechanical
|
||||
; centre is the middle of pitch travel, which sits well above the horizon.
|
||||
; A yaw-only tower has no pitch travel: set this to its camera's fixed tilt
|
||||
; (0 if level) - it is the CamEvent pitch there when no IMU is fitted.
|
||||
pitch_angle_at_center = 10.0
|
||||
pitch_soft_margin_counts = 5000
|
||||
; Live encoder-error WARN threshold in counts (0 = off).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# Scan grid - capture waypoints, one "yaw_deg,pitch_deg" per line.
|
||||
# Scan grid - capture waypoints, one "yaw_deg,pitch_deg" per line ("yaw_deg"
|
||||
# alone means pitch 0; a yaw-only tower ignores the pitch).
|
||||
# Edit these to define the exact scan coordinates. Blank lines and lines
|
||||
# starting with '#' are ignored. This file is the only source of waypoints:
|
||||
# nothing is generated, so what the tower photographs is always what is written
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 1 and column 38.
|
|
|
@ -73,7 +73,8 @@ corrupted.
|
|||
the `mode` command (console, dashboard keys, or MQTT `Command`) through `Application::setMode`, which
|
||||
publishes it on StatusCode. The same header's `allowedIn()` gates every command by mode.
|
||||
4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → wait → keep machine:
|
||||
- auto: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap).
|
||||
- auto: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap). A yaw-only
|
||||
tower (no `P:` segment in `ST`) gets `MOVE Y <yaw>` instead, as its firmware refuses the pair.
|
||||
- pan: `MOVE` to the next of `panHeadings()` (left to right, then back to the left edge) at the
|
||||
pan's pitch, converted to counts via `Geometry`.
|
||||
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
|
||||
|
|
@ -84,7 +85,8 @@ corrupted.
|
|||
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
||||
capture* below.
|
||||
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
|
||||
`ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU —
|
||||
`ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU
|
||||
(no IMU: the pitch encoder; no pitch axis either: the fixed `pitch_angle_at_center`) —
|
||||
and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier
|
||||
pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL, writes
|
||||
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose.
|
||||
|
|
@ -97,7 +99,8 @@ corrupted.
|
|||
▼
|
||||
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
|
||||
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
|
||||
└──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer
|
||||
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer
|
||||
└──────────┬───────────┘
|
||||
│ (both axes standstill AND |xenc − target| ≤ tol)
|
||||
▼
|
||||
┌──────────────────────┐ the camera keeps streaming throughout; this pause is
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
|||
| `Motor` | `yaw_counts_per_deg` / `pitch_counts_per_deg` | float | `983.33` / — | Encoder counts per degree (**calibrate**; may be negative to flip) |
|
||||
| `Motor` | `yaw_center_count` / `pitch_center_count` | int | `500000` | Count homing **defines** as the middle of the found travel. **Must exceed half the travel**, or the negative end falls below zero — the `500000` default suits the Delta's ~89k half-travel, not a geared-up rig (the IOTA needs 1000000). A warning names it after each home |
|
||||
| `Motor` | `yaw_heading_at_center` | float | `0` | True bearing (0 = north) at that centre (**calibrate**) |
|
||||
| `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig) |
|
||||
| `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig). On a yaw-only tower, the camera's fixed tilt, published as CamEvent `pit` when no IMU is fitted |
|
||||
| `Motor` | `yaw_soft_margin_counts` / `pitch_soft_margin_counts` | int | `5000` | Clearance kept from each hard endstop; sent to the firmware, which enforces it |
|
||||
| `Motor` | `homing_timeout_s` | int | `65` | How long a full two-end homing may take before the host calls it failed. A property of the drivetrain — a high gear ratio searching gently takes minutes |
|
||||
| `Motor.<axis>` | `r_sense_ohm` | float | — | Sense resistor on that axis's TMC5160 board. **hardware.ini.** Makes the milliamp figures below mean something |
|
||||
|
|
@ -86,7 +86,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
|||
| `Motor.<axis>` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **hardware.ini** |
|
||||
| `Motor.<axis>` | `enc_const` | float | 12.8 | Microsteps per encoder count (the TMC's `ENC_CONST`), up to 4 decimals. **hardware.ini**, optional: only a drivetrain whose encoder differs declares it. Wrong by a few percent, the step counter and encoder drift apart as the axis turns, and homing takes the drift for a stop (staeffelsberg: 12.5, from its old firmware) |
|
||||
| `Motor.<axis>` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** |
|
||||
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints. The only source of a scan grid; empty → auto-sweep disabled |
|
||||
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints (`yaw_deg` alone means pitch 0). The only source of a scan grid; empty → auto-sweep disabled |
|
||||
|
||||
### `[Motor]` calibration & `[Scan]` grid
|
||||
|
||||
|
|
@ -192,6 +192,10 @@ nearest limit — clamping would image a direction nobody asked for and publish
|
|||
they did. A one-line audit after each home reports how many waypoints are affected. Write an arc
|
||||
crossing north monotonically (`340` → `380`) rather than letting it wrap back through zero.
|
||||
|
||||
**A line may be a yaw alone** (`45`), meaning pitch 0. A **yaw-only tower** ignores the pitch column
|
||||
and moves yaw alone (`MOVE Y`), so one grid serves either kind of tower. Rows that differ only in pitch
|
||||
photograph the same bearing again there; the audit after each home warns how many do.
|
||||
|
||||
Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks.
|
||||
|
||||
### `[IMU]` — Xsens MTi orientation sensor
|
||||
|
|
@ -493,7 +497,8 @@ this *run*, not a statement about what is bolted to the tower.
|
|||
### Declared vs found — what `hardware.ini` is checked against
|
||||
|
||||
Everything in this program degrades gracefully: `TestRunner` skips the pitch and IMU leaves, `CamEvent`
|
||||
falls back to encoder pitch, `CaptureScheduler` sends the single-axis `MOVE Y` form. That is correct for a
|
||||
falls back to encoder pitch (or, with no pitch axis, the fixed `pitch_angle_at_center`), `CaptureScheduler`
|
||||
and `gimbal move` send the single-axis `MOVE Y` form, and pitch-only commands are refused. That is correct for a
|
||||
rig that genuinely lacks them, and **indistinguishable from a failure on a rig that does not**. A two-axis
|
||||
tower whose pitch stops enumerating becomes a yaw-only tower, captures one elevation band, and reports
|
||||
itself healthy.
|
||||
|
|
@ -706,7 +711,7 @@ binding. `a` auto, `p` pan (30° around where the gimbal points now) and `m` man
|
|||
the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The
|
||||
overview adds `h` home, `g s c n` views and `f` pause; an expanded view offers its own (`d` dump in
|
||||
the gimbal view, `Esc` close). In manual mode the arrow keys nudge the gimbal
|
||||
(yaw ±5 % / pitch ±10 %) from any view, `Space` takes a picture (`snapshot`) and `r` toggles continuous
|
||||
(yaw ±5 % / pitch ±10 %; a yaw-only tower has only ←→, and the bar shows just those) from any view, `Space` takes a picture (`snapshot`) and `r` toggles continuous
|
||||
capture (`capture on|off`). Every key that acts sends a command, so nothing is dashboard-only. Every binding still *works*
|
||||
everywhere; only the advertising changes.
|
||||
|
||||
|
|
@ -763,8 +768,8 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc
|
|||
| `CURRENT Y\|P <scaler> <irun> <irun_home>` | init, before `HOME` | motor current as TMC register fields, converted by the host from `[Motor.<axis>]` milliamps |
|
||||
| `CENTER Y\|P <count>` | init, before `HOME` | the count homing anchors the travel midpoint to |
|
||||
| `MARGIN Y\|P <counts>` | init, before `HOME` | clearance kept from each hard endstop |
|
||||
| `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) |
|
||||
| `MOVE Y\|P <pos>` | — | single-axis absolute move (`gimbal raw`) |
|
||||
| `MOVE <yaw>,<pitch>` | each capture point; `gimbal move/steps` with a pitch | drive both axes to absolute counts (combined form) |
|
||||
| `MOVE Y\|P <pos>` | each capture point on a yaw-only tower; `gimbal move/steps` without a pitch; `nudge`, `jog` | single-axis absolute move. A yaw-only firmware refuses the combined form, so the host sends `MOVE Y` there |
|
||||
| `STOP Y\|P\|ALL` | — | ramp to a controlled stop |
|
||||
|
||||
### Motor current, and why the host computes it
|
||||
|
|
|
|||
|
|
@ -359,8 +359,8 @@ This lands in `updateIniSectionKeys` + `saveMotorGeometry`, and `saveCalibration
|
|||
## 10. Declaring presence
|
||||
|
||||
The existing code degrades gracefully everywhere — `TestRunner` skips the pitch and IMU leaves, CamEvent
|
||||
falls back to encoder pitch, `HeadingFit` is IMU-free by design, `CaptureScheduler` sends the single-axis
|
||||
`MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a
|
||||
falls back to encoder pitch (or the fixed `pitch_angle_at_center` with no pitch axis), `HeadingFit` is
|
||||
IMU-free by design, `CaptureScheduler` and `gimbal move` send the single-axis `MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a
|
||||
failure is indistinguishable from a configuration**: a 2-axis tower whose pitch stops enumerating becomes
|
||||
a yaw-only tower, captures one elevation band, and reports itself healthy.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
|||
| [include/fgc/Paths.h](../include/fgc/Paths.h), [src/core/Paths.cpp](../src/core/Paths.cpp) | `~`/`$ENV` expansion, executable dir, config search order, default output dir |
|
||||
| [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger + per-category wire trace; `LOG_TRACE..LOG_ERROR`, `LOG_TRACE_CAT` |
|
||||
| [include/fgc/FileLogSink.h](../include/fgc/FileLogSink.h), [src/core/FileLogSink.cpp](../src/core/FileLogSink.cpp) | The persistent log file: append, size rotation, count-based pruning, a self-describing header per file. Runs inside `Logger`'s non-recursive lock, so it never logs - failures latch into `failed()`/`lastError()` for the caller to report |
|
||||
| [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration) |
|
||||
| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV only, never generated) + one-way wrapping cursor (`[Scan]`) |
|
||||
| [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration); `encoderPitchDeg` (the CamEvent pitch when there is no IMU — the fixed `deg_at_center` on a yaw-only tower) |
|
||||
| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV only, never generated; a bare `yaw` line is pitch 0) + one-way wrapping cursor (`[Scan]`); `repeatedBearings` for the yaw-only audit |
|
||||
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional<MotorTelemetry>` |
|
||||
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
|
||||
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command`; `parseAim` (`<yaw>[,<pitch>]` for `gimbal move/steps`) and `moveCommand` (the firmware `MOVE`: the pair, or `MOVE Y` without a pitch) |
|
||||
| [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) |
|
||||
| [include/fgc/TmcCurrent.h](../include/fgc/TmcCurrent.h), [src/core/TmcCurrent.cpp](../src/core/TmcCurrent.cpp) | Milliamps → TMC5160 `GLOBALSCALER`/`IRUN` register fields, using the sense resistor from `[Motor.<axis>]`. Pure arithmetic, so it is unit-tested; the host owns the conversion because only it knows the resistor |
|
||||
| [include/fgc/HardwarePresence.h](../include/fgc/HardwarePresence.h), [src/core/HardwarePresence.cpp](../src/core/HardwarePresence.cpp) | `reconcilePresence` (what `hardware.ini` declares vs what actually came up) + `presenceVerdict` (proceed / warn / refuse, per `strict_presence` and `--degraded`). Pure comparison, so the policy is unit-tested without a rig |
|
||||
|
|
@ -89,6 +89,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
|||
Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state`
|
||||
(`AxisState` B/R/H/A/E/C — the last being NEEDCFG, a firmware waiting to be told its drivetrain), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags
|
||||
`standstill`/`stall`/`deviation`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers.
|
||||
`MotorTelemetry::pitchAbsent()` is true for a yaw-only tower — a status line has arrived without a `P:`
|
||||
segment — and is what the host checks before refusing a pitch command.
|
||||
Parsed from the firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
|
||||
|
||||
`deviation` (flag `d`) is the TMC5160's hardware encoder-deviation latch — set when `|xactual − xenc|`
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ measured), so the span is cut into `ceil(span / step)` equal intervals, both
|
|||
edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not
|
||||
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
|
||||
span under one picture is the one heading, photographed once per loop. Pitch is
|
||||
optional and defaults to 0 (level). Pictures are taken at the same pace as auto
|
||||
optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
|
||||
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture
|
||||
does.
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
|||
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
|
||||
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
|
||||
| `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
|
||||
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle |
|
||||
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) |
|
||||
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
|
||||
|
||||
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
|
||||
|
|
@ -123,6 +123,13 @@ If the IMU is disabled (`[Features] enable_imu = false`), `pit` falls back to th
|
|||
before. If the IMU is enabled but has no fresh sample, it also falls back — and logs a
|
||||
warning saying so, once per outage, since that mixes the two measurements in one field.
|
||||
|
||||
A **yaw-only tower** has no pitch encoder. With an IMU, `pit` is still the IMU's elevation;
|
||||
without one it is the camera's fixed elevation, `[Motor] pitch_angle_at_center` (0 unless
|
||||
set). Commands keep one syntax on every tower: `gimbal move <yaw>[,<pitch>]` and `mode pan`
|
||||
accept a pitch and ignore it there (with a warning), and pitch-only commands
|
||||
(`gimbal nudge|jog pitch`, `gimbal pitch setup`, `gimbal <verb> p`) are refused with
|
||||
"this tower has no pitch axis".
|
||||
|
||||
Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG XL encode
|
||||
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
|
||||
pixels.
|
||||
|
|
|
|||
|
|
@ -836,7 +836,7 @@ Then, and only then, move the thing:
|
|||
```
|
||||
gimbal home # the endstop-finding sequence - see the warning below
|
||||
gimbal diag y # firmware self-test, reports PASS/FAIL
|
||||
gimbal move 10,0 # a small deliberate move
|
||||
gimbal move 10,0 # a small deliberate move (a yaw-only tower: gimbal move 10)
|
||||
```
|
||||
|
||||
**Expected noise in this phase, neither of which is a fault:**
|
||||
|
|
@ -939,7 +939,7 @@ different gear ratio, where `yaw_counts_per_deg` is derived from a
|
|||
|
||||
```
|
||||
gimbal heading setup # ties yaw to true north by sighting landmarks; no IMU needed
|
||||
gimbal pitch setup # needs an IMU; skip on a rig without one
|
||||
gimbal pitch setup # needs an IMU and a pitch axis; skip on a rig without them
|
||||
```
|
||||
|
||||
## 10b. Bringing up the config handshake on an existing tower
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
|
|
@ -25,4 +27,18 @@ struct Command {
|
|||
// Replaces the fragile Boost.Spirit Qi grammar.
|
||||
Command parseCommand(const std::string& line);
|
||||
|
||||
// The target of `gimbal move` / `gimbal steps`: "<yaw>[,<pitch>]", also written
|
||||
// "<yaw> <pitch>", read from tokens[from] onward. Pitch is optional, so the same
|
||||
// command aims a yaw-only tower. nullopt when yaw is missing, a value is not a
|
||||
// number, or anything follows the pitch.
|
||||
struct Aim {
|
||||
double yaw = 0.0;
|
||||
std::optional<double> pitch;
|
||||
};
|
||||
std::optional<Aim> parseAim(const std::vector<std::string>& tokens, size_t from);
|
||||
|
||||
// The firmware MOVE to absolute counts: "MOVE <yaw>,<pitch>" with a pitch, else
|
||||
// "MOVE Y <yaw>" - which a yaw-only firmware needs, as it refuses the pair.
|
||||
std::string moveCommand(long yaw_counts, std::optional<long> pitch_counts);
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ struct AxisMap {
|
|||
std::optional<long> headingToCounts(double heading_deg) const;
|
||||
};
|
||||
|
||||
// Pitch in degrees from the pitch encoder's counts. A yaw-only tower has no pitch
|
||||
// encoder (nullopt): its camera sits at a fixed elevation, deg_at_center.
|
||||
double encoderPitchDeg(const AxisMap& pitch, std::optional<long> counts);
|
||||
|
||||
// Per-axis maps for the two-axis gimbal.
|
||||
struct Geometry {
|
||||
AxisMap yaw;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ struct MotorTelemetry {
|
|||
AxisTelemetry yaw;
|
||||
AxisTelemetry pitch;
|
||||
bool pitch_present = false; // was a P: segment seen?
|
||||
|
||||
// A yaw-only tower: the firmware is talking and its status line has no pitch
|
||||
// segment. Before the first status line nothing is known, so this is false.
|
||||
bool pitchAbsent() const { return yaw.state != AxisState::Unknown && !pitch_present; }
|
||||
};
|
||||
|
||||
// Abstraction over the gimbal's motor controller. Implemented by
|
||||
|
|
|
|||
|
|
@ -53,4 +53,8 @@ private:
|
|||
// Exposed for unit testing.
|
||||
std::vector<ScanPoint> parseScanCsv(const std::string& text);
|
||||
|
||||
// Waypoints whose bearing an earlier one already has. On a yaw-only tower the
|
||||
// pitch column is ignored, so each of these photographs that bearing again.
|
||||
std::size_t repeatedBearings(const std::vector<ScanPoint>& points);
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -1747,6 +1747,12 @@ struct Application::Impl {
|
|||
// without this the only symptom would be a grid quietly shorter than the CSV.
|
||||
void auditScanReachability() {
|
||||
if (grid.empty()) return;
|
||||
// A yaw-only tower ignores the pitch column, so rows that differ only in
|
||||
// pitch photograph the same bearing again.
|
||||
if (noPitchAxis())
|
||||
if (const std::size_t repeats = repeatedBearings(grid.points()))
|
||||
LOG_WARN << repeats << " scan waypoints repeat an earlier bearing: this tower "
|
||||
"has no pitch axis, so the pitch column is ignored";
|
||||
std::size_t bad = 0;
|
||||
for (const auto& p : grid.points())
|
||||
if (!cfg.geometry.yaw.headingToCounts(p.yaw_deg)) ++bad;
|
||||
|
|
@ -1803,8 +1809,7 @@ struct Application::Impl {
|
|||
scheduler->setMode(m, pan);
|
||||
if (m == Mode::Pan) {
|
||||
const auto& h = scheduler->panHeadingsNow();
|
||||
LOG_INFO << "mode pan: heading " << pan.heading << ", span " << pan.span
|
||||
<< ", pitch " << pan.pitch << " deg - " << h.size()
|
||||
LOG_INFO << "mode pan: " << panText(pan) << " - " << h.size()
|
||||
<< (h.size() == 1 ? " position" : " positions") << " ("
|
||||
<< panRangeText(h) << ")";
|
||||
} else {
|
||||
|
|
@ -1818,6 +1823,9 @@ struct Application::Impl {
|
|||
// clamped to it - that would image an elevation nobody asked for), and at
|
||||
// least one of its headings inside the reachable yaw arc.
|
||||
bool checkPan(const PanSpec& pan) {
|
||||
if (pan.pitch != 0.0 && noPitchAxis())
|
||||
LOG_WARN << "mode pan: this tower has no pitch axis; pitch " << pan.pitch
|
||||
<< " deg ignored";
|
||||
const AxisMap& p = cfg.geometry.pitch;
|
||||
if (cfg.hardware.hasAxis("pitch") && (pan.pitch < p.min_deg || pan.pitch > p.max_deg)) {
|
||||
LOG_WARN << "mode pan refused: pitch " << pan.pitch << " deg is outside the homed "
|
||||
|
|
@ -1840,6 +1848,15 @@ struct Application::Impl {
|
|||
return true;
|
||||
}
|
||||
|
||||
// "heading 90, span 30, pitch 5 deg" - no pitch on a yaw-only tower.
|
||||
std::string panText(const PanSpec& p) const {
|
||||
std::ostringstream o;
|
||||
o << "heading " << p.heading << ", span " << p.span;
|
||||
if (!noPitchAxis()) o << ", pitch " << p.pitch;
|
||||
o << " deg";
|
||||
return o.str();
|
||||
}
|
||||
|
||||
static std::string panRangeText(const std::vector<double>& h) {
|
||||
if (h.empty()) return "none";
|
||||
std::ostringstream o;
|
||||
|
|
@ -1867,8 +1884,7 @@ struct Application::Impl {
|
|||
if (m == Mode::Pan) {
|
||||
const PanSpec p = scheduler->pan();
|
||||
const auto& h = scheduler->panHeadingsNow();
|
||||
LOG_INFO << "mode pan: heading " << p.heading << ", span " << p.span << ", pitch "
|
||||
<< p.pitch << " deg - " << h.size()
|
||||
LOG_INFO << "mode pan: " << panText(p) << " - " << h.size()
|
||||
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
|
||||
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
|
||||
} else {
|
||||
|
|
@ -2086,6 +2102,28 @@ struct Application::Impl {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Before the first status line nothing is known, so nothing is refused - the
|
||||
// firmware answers for itself.
|
||||
bool noPitchAxis() const { return motor->telemetry().pitchAbsent(); }
|
||||
|
||||
bool requirePitch(const char* what) {
|
||||
if (!noPitchAxis()) return true;
|
||||
LOG_WARN << "ignored '" << what << "': this tower has no pitch axis";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Aim at absolute counts. Without a pitch, or on a yaw-only tower (whose
|
||||
// firmware refuses the two-axis MOVE), yaw moves alone and pitch stays put.
|
||||
void sendAim(long yaw_counts, std::optional<long> pitch_counts) {
|
||||
if (pitch_counts && noPitchAxis()) {
|
||||
LOG_WARN << "this tower has no pitch axis; moving yaw only";
|
||||
pitch_counts.reset();
|
||||
}
|
||||
const std::string fw = moveCommand(yaw_counts, pitch_counts);
|
||||
LOG_INFO << "gimbal -> " << fw;
|
||||
motor->sendCommand(fw);
|
||||
}
|
||||
|
||||
// `trace off|none`, `trace all`, or `trace <cat> [on|off]` — toggle the
|
||||
// verbatim wire-trace categories (serial/mqtt/camera/control).
|
||||
void handleTrace(const Command& c) {
|
||||
|
|
@ -2107,20 +2145,6 @@ struct Application::Impl {
|
|||
LOG_INFO << "trace categories: " << traceNames(Logger::categories());
|
||||
}
|
||||
|
||||
// `goto <yaw_deg> <pitch_deg>` — aim the gimbal at an absolute heading and
|
||||
// elevation in degrees. Converts to encoder counts via the operator-calibrated
|
||||
// Geometry maps (which soft-clamp to the travel limits) and issues a two-axis
|
||||
// MOVE so both axes start together.
|
||||
// Parse "<a>,<b>" or "<a> <b>" (tokens from `from` onward) into two numbers.
|
||||
static bool parsePair(const std::vector<std::string>& tok, size_t from,
|
||||
double& a, double& b) {
|
||||
std::string s;
|
||||
for (size_t i = from; i < tok.size(); ++i) s += tok[i] + " ";
|
||||
for (char& ch : s) if (ch == ',') ch = ' ';
|
||||
std::istringstream iss(s);
|
||||
return static_cast<bool>(iss >> a >> b);
|
||||
}
|
||||
|
||||
// Cross-check the degree scale against the travel homing just measured, and
|
||||
// bootstrap it when it is obviously wrong.
|
||||
//
|
||||
|
|
@ -2222,26 +2246,37 @@ struct Application::Impl {
|
|||
}
|
||||
|
||||
if (sub == "move") {
|
||||
double yaw_deg = 0, pitch_deg = 0;
|
||||
if (!parsePair(tok, 2, yaw_deg, pitch_deg)) {
|
||||
LOG_WARN << "usage: gimbal move <yaw_deg>,<pitch_deg>";
|
||||
auto aim = parseAim(tok, 2);
|
||||
if (!aim) {
|
||||
LOG_WARN << "usage: gimbal move <yaw_deg>[,<pitch_deg>]";
|
||||
return;
|
||||
}
|
||||
long yc = cfg.geometry.yaw.toCounts(yaw_deg);
|
||||
long pc = cfg.geometry.pitch.toCounts(pitch_deg);
|
||||
LOG_INFO << "gimbal move yaw=" << yaw_deg << " pitch=" << pitch_deg
|
||||
<< " deg -> MOVE " << yc << "," << pc;
|
||||
motor->sendCommand("MOVE " + std::to_string(yc) + "," + std::to_string(pc));
|
||||
// Yaw is a true bearing: one in the arc the gimbal cannot reach is
|
||||
// refused, as a pan's are - clamping would image the wrong direction.
|
||||
auto yc = cfg.geometry.yaw.headingToCounts(aim->yaw);
|
||||
if (!yc) {
|
||||
LOG_WARN << "gimbal move refused: bearing " << wrap360(aim->yaw)
|
||||
<< " deg is outside the reachable yaw arc ("
|
||||
<< wrap360(cfg.geometry.yaw.min_deg) << ".."
|
||||
<< wrap360(cfg.geometry.yaw.max_deg) << " true)";
|
||||
return;
|
||||
}
|
||||
std::optional<long> pc;
|
||||
if (aim->pitch) pc = cfg.geometry.pitch.toCounts(*aim->pitch);
|
||||
sendAim(*yc, pc);
|
||||
} else if (sub == "steps") {
|
||||
double yc = 0, pc = 0;
|
||||
if (!parsePair(tok, 2, yc, pc)) {
|
||||
LOG_WARN << "usage: gimbal steps <yaw_counts>,<pitch_counts>";
|
||||
auto aim = parseAim(tok, 2);
|
||||
if (!aim) {
|
||||
LOG_WARN << "usage: gimbal steps <yaw_counts>[,<pitch_counts>]";
|
||||
return;
|
||||
}
|
||||
motor->sendCommand("MOVE " + std::to_string((long)yc) + "," + std::to_string((long)pc));
|
||||
std::optional<long> pc;
|
||||
if (aim->pitch) pc = static_cast<long>(*aim->pitch);
|
||||
sendAim(static_cast<long>(aim->yaw), pc);
|
||||
} else if (sub == "nudge") {
|
||||
if (tok.size() < 4) { LOG_WARN << "usage: gimbal nudge <yaw|pitch> <+/-pct>"; return; }
|
||||
char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
|
||||
if (axis == 'P' && !requirePitch("gimbal nudge pitch")) return;
|
||||
double pct = 0;
|
||||
try { pct = std::stod(tok[3]); } catch (...) { LOG_WARN << "gimbal nudge: bad percent"; return; }
|
||||
// Nudge is a fraction of the *homed* physical travel (endstop-to-endstop
|
||||
|
|
@ -2265,6 +2300,7 @@ struct Application::Impl {
|
|||
// heading wizard needs, and counts are what the encoder resolves.
|
||||
if (tok.size() < 4) { LOG_WARN << "usage: gimbal jog <yaw|pitch> <+/-counts>"; return; }
|
||||
char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
|
||||
if (axis == 'P' && !requirePitch("gimbal jog pitch")) return;
|
||||
long delta = 0;
|
||||
try { delta = std::stol(tok[3]); } catch (...) { LOG_WARN << "gimbal jog: bad count"; return; }
|
||||
MotorTelemetry tel = motor->telemetry();
|
||||
|
|
@ -2309,6 +2345,9 @@ struct Application::Impl {
|
|||
sub == "disable" || sub == "speed" || sub == "setpos" ||
|
||||
sub == "status" || sub == "current") {
|
||||
// Passthrough to the (case-insensitive) firmware verb + args.
|
||||
if (tok.size() > 2 && lowerStr(tok[2]) == "p" &&
|
||||
!requirePitch(("gimbal " + sub + " p").c_str()))
|
||||
return;
|
||||
std::string fw = sub;
|
||||
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch));
|
||||
for (size_t i = 2; i < tok.size(); ++i) fw += " " + tok[i];
|
||||
|
|
@ -2326,6 +2365,7 @@ struct Application::Impl {
|
|||
// Pitch is the one axis that genuinely needs the IMU: level is defined by
|
||||
// gravity, and nothing else on the tower can measure it. Yaw does not -
|
||||
// `gimbal heading setup` uses landmarks instead.
|
||||
if (!requirePitch("gimbal pitch setup")) return;
|
||||
if (!imu) {
|
||||
LOG_WARN << "gimbal pitch setup requires the IMU (declare [Hardware] imu); "
|
||||
"yaw does not - use 'gimbal heading setup'";
|
||||
|
|
@ -2985,13 +3025,18 @@ struct Application::Impl {
|
|||
// pitch - the IMU. The encoder only measures the axis relative to the
|
||||
// gimbal's own base, so a leaning mast is invisible to it; the
|
||||
// IMU measures against gravity, which is the true elevation the
|
||||
// imagery needs to be georeferenced against.
|
||||
// imagery needs to be georeferenced against. A yaw-only tower
|
||||
// has no pitch encoder: its camera sits at a fixed elevation,
|
||||
// [Motor] pitch_angle_at_center (0 unless set).
|
||||
pipeline = std::make_unique<ImagePipeline>(
|
||||
*channel,
|
||||
[this] {
|
||||
MotorTelemetry t = motor->telemetry();
|
||||
const double pitch = encoderPitchDeg(
|
||||
cfg.geometry.pitch,
|
||||
t.pitch_present ? std::optional<long>(t.pitch.xenc) : std::nullopt);
|
||||
Orientation o{static_cast<float>(cfg.geometry.yaw.headingDeg(t.yaw.xenc)),
|
||||
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
|
||||
static_cast<float>(pitch)};
|
||||
if (!imu) return o; // IMU disabled in config: encoder pitch, as before
|
||||
if (auto s = imu->sample()) {
|
||||
o.pitch_deg = s->pitch_deg;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "fgc/CaptureScheduler.h"
|
||||
|
||||
#include "fgc/CommandParser.h"
|
||||
#include "fgc/Logger.h"
|
||||
|
||||
#include <chrono>
|
||||
|
|
@ -72,12 +73,8 @@ void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
|
|||
void CaptureScheduler::sendMove() {
|
||||
// On a 1-axis (yaw-only) rig the firmware rejects the two-axis `MOVE y,p`
|
||||
// form ("ERR axis not present") and moves nothing, so address yaw alone.
|
||||
if (pitch_present_) {
|
||||
motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," +
|
||||
std::to_string(pitch_target_));
|
||||
} else {
|
||||
motor_.sendCommand("MOVE Y " + std::to_string(yaw_target_));
|
||||
}
|
||||
motor_.sendCommand(moveCommand(yaw_target_, pitch_present_ ? std::optional<long>(pitch_target_)
|
||||
: std::nullopt));
|
||||
}
|
||||
|
||||
bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
|
||||
|
|
|
|||
|
|
@ -53,4 +53,28 @@ Command parseCommand(const std::string& line) {
|
|||
return cmd;
|
||||
}
|
||||
|
||||
std::optional<Aim> parseAim(const std::vector<std::string>& tokens, size_t from) {
|
||||
std::vector<std::string> nums;
|
||||
for (size_t i = from; i < tokens.size(); ++i) {
|
||||
std::istringstream parts(tokens[i]);
|
||||
for (std::string n; std::getline(parts, n, ',');)
|
||||
if (!n.empty()) nums.push_back(n);
|
||||
}
|
||||
if (nums.empty() || nums.size() > 2) return std::nullopt;
|
||||
Aim a;
|
||||
if (!parseNumber(nums[0], a.yaw)) return std::nullopt;
|
||||
if (nums.size() == 2) {
|
||||
double p = 0.0;
|
||||
if (!parseNumber(nums[1], p)) return std::nullopt;
|
||||
a.pitch = p;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
std::string moveCommand(long yaw_counts, std::optional<long> pitch_counts) {
|
||||
if (pitch_counts)
|
||||
return "MOVE " + std::to_string(yaw_counts) + "," + std::to_string(*pitch_counts);
|
||||
return "MOVE Y " + std::to_string(yaw_counts);
|
||||
}
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -46,4 +46,8 @@ std::optional<long> AxisMap::headingToCounts(double heading_deg) const {
|
|||
return toCounts(deg);
|
||||
}
|
||||
|
||||
double encoderPitchDeg(const AxisMap& pitch, std::optional<long> counts) {
|
||||
return counts ? pitch.toDeg(*counts) : pitch.deg_at_center;
|
||||
}
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ const std::vector<HelpSection>& helpCatalog() {
|
|||
"status, logging, 'mode', 'cancel' and 'exit' are accepted."}},
|
||||
{"mode pan <heading> <span> [<pitch>]", "Sweep a span centred on a bearing, repeatedly (key p).", {
|
||||
"One image every pan_step_deg (hardware.ini), both edges included.",
|
||||
"Pitch defaults to 0 (level). Key p pans 30 deg around the current aim.",
|
||||
"Pitch defaults to 0 (level); a yaw-only tower ignores it. Key p pans",
|
||||
"30 deg around the current aim.",
|
||||
"Example: mode pan 90 30"}},
|
||||
{"mode manual", "Stop scanning; aim by hand (key m). Cancels a running procedure.", {}},
|
||||
}},
|
||||
|
|
@ -52,10 +53,12 @@ const std::vector<HelpSection>& helpCatalog() {
|
|||
"For a stream that has stopped and will not restart. Takes seconds."}},
|
||||
}},
|
||||
{"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", {
|
||||
{"gimbal move <yaw>,<pitch>", "Point at a bearing/elevation in degrees.", {
|
||||
{"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", {
|
||||
"Bearings in the unreachable yaw arc are refused, not clamped.",
|
||||
"Example: gimbal move 30,-10"}},
|
||||
{"gimbal steps <yaw>,<pitch>", "Move to absolute encoder counts.", {
|
||||
"Without a pitch, only yaw moves. A yaw-only tower ignores the pitch",
|
||||
"and refuses the pitch-only commands below.",
|
||||
"Example: gimbal move 30,-10 / gimbal move 30"}},
|
||||
{"gimbal steps <yaw>[,<pitch>]", "Move to absolute encoder counts.", {
|
||||
"Example: gimbal steps 100000,250000"}},
|
||||
{"gimbal nudge <yaw|pitch> <+/-pct>", "Relative move, percent of travel (arrow keys).", {
|
||||
"Example: gimbal nudge yaw -5"}},
|
||||
|
|
@ -106,7 +109,7 @@ const std::vector<HelpSection>& helpCatalog() {
|
|||
{"a / p / m", "mode auto / mode pan (30 deg around the aim) / mode manual.", {}},
|
||||
{"h", "gimbal home.", {}},
|
||||
{"Space / r", "snapshot / capture on|off.", {}},
|
||||
{"Arrows", "gimbal nudge: yaw 5%, pitch 10% (manual).", {}},
|
||||
{"Arrows", "gimbal nudge: yaw 5%, pitch 10% if the tower has it (manual).", {}},
|
||||
{"Esc", "Close the view, or cancel a running procedure.", {}},
|
||||
{"? / g / s / c / n", "Views: help, gimbal, sensors, cameras, connectivity.", {}},
|
||||
{"PgUp / PgDn", "Scroll the log or the open view.", {}},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
#include "fgc/ScanGrid.h"
|
||||
|
||||
#include "fgc/Geometry.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
|
@ -27,14 +30,12 @@ std::vector<ScanPoint> parseScanCsv(const std::string& text) {
|
|||
++lineno;
|
||||
std::string t = trim(line);
|
||||
if (t.empty() || t[0] == '#') continue;
|
||||
// "yaw_deg,pitch_deg", or "yaw_deg" alone for pitch 0 (a yaw-only tower).
|
||||
size_t comma = t.find(',');
|
||||
if (comma == std::string::npos)
|
||||
throw std::runtime_error("scan grid line " + std::to_string(lineno) +
|
||||
": expected 'yaw_deg,pitch_deg', got '" + t + "'");
|
||||
try {
|
||||
ScanPoint p;
|
||||
p.yaw_deg = std::stod(trim(t.substr(0, comma)));
|
||||
p.pitch_deg = std::stod(trim(t.substr(comma + 1)));
|
||||
if (comma != std::string::npos) p.pitch_deg = std::stod(trim(t.substr(comma + 1)));
|
||||
pts.push_back(p);
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error("scan grid line " + std::to_string(lineno) +
|
||||
|
|
@ -44,6 +45,14 @@ std::vector<ScanPoint> parseScanCsv(const std::string& text) {
|
|||
return pts;
|
||||
}
|
||||
|
||||
std::size_t repeatedBearings(const std::vector<ScanPoint>& points) {
|
||||
std::set<double> seen;
|
||||
std::size_t repeats = 0;
|
||||
for (const auto& p : points)
|
||||
if (!seen.insert(wrap360(p.yaw_deg)).second) ++repeats;
|
||||
return repeats;
|
||||
}
|
||||
|
||||
ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) {
|
||||
// No grid_file, no grid. Generating a default sweep here would put a tower
|
||||
// on the air photographing bearings nobody picked, under a configuration
|
||||
|
|
|
|||
|
|
@ -235,14 +235,16 @@ std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg)
|
|||
//
|
||||
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
|
||||
// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
|
||||
Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
|
||||
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93";
|
||||
Element keyBar(Overlay overlay, bool manual, bool heading_setup, bool pitch_present) {
|
||||
// ←→↑↓, or just ←→ on a yaw-only tower.
|
||||
const char* arrows = pitch_present ? "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"
|
||||
: "\xE2\x86\x90\xE2\x86\x92";
|
||||
|
||||
Elements e;
|
||||
if (heading_setup) {
|
||||
// Replaces the common set: mode changes are refused during heading
|
||||
// setup, so advertising them here would only mislead.
|
||||
e.push_back(keyHint(kArrows4, "Aim"));
|
||||
e.push_back(keyHint(arrows, "Aim"));
|
||||
e.push_back(keyHint("Tab", "Step"));
|
||||
e.push_back(keyHint("Enter", "Mark"));
|
||||
e.push_back(keyHint("Spc", "Snap"));
|
||||
|
|
@ -274,7 +276,7 @@ Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
|
|||
// Hand aiming is refused outside manual, so stop advertising it rather
|
||||
// than inviting a keypress that only warns.
|
||||
if (manual) {
|
||||
e.push_back(keyHint(kArrows4, "Nudge"));
|
||||
e.push_back(keyHint(arrows, "Nudge"));
|
||||
e.push_back(keyHint("Spc", "Snap"));
|
||||
e.push_back(keyHint("r", "Capture"));
|
||||
}
|
||||
|
|
@ -1352,7 +1354,8 @@ void TuiUi::uiLoop() {
|
|||
Element bottom = command_mode
|
||||
? hbox({text(" : ") | inverted, input->Render() | flex}) | border
|
||||
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
|
||||
: keyBar(overlay, s.capture.manual, heading_setup);
|
||||
: keyBar(overlay, s.capture.manual, heading_setup,
|
||||
s.gimbal.pitch_present);
|
||||
|
||||
// An open overlay takes over the whole body (the dashboard panels are
|
||||
// hidden) so it has the full height — otherwise tall content like the
|
||||
|
|
@ -1459,9 +1462,9 @@ void TuiUi::uiLoop() {
|
|||
if (e == Event::ArrowLeft) { jog("yaw", yaw_travel, -1); return true; }
|
||||
if (e == Event::ArrowRight) { jog("yaw", yaw_travel, +1); return true; }
|
||||
// Pitch too: the landmark has to be in frame vertically before its
|
||||
// bearing means anything.
|
||||
if (e == Event::ArrowUp) { jog("pitch", pitch_travel, +1); return true; }
|
||||
if (e == Event::ArrowDown) { jog("pitch", pitch_travel, -1); return true; }
|
||||
// bearing means anything. A yaw-only tower has nothing to send.
|
||||
if (e == Event::ArrowUp) { if (pitch_present) jog("pitch", pitch_travel, +1); return true; }
|
||||
if (e == Event::ArrowDown) { if (pitch_present) jog("pitch", pitch_travel, -1); return true; }
|
||||
if (e == Event::Tab) {
|
||||
jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine
|
||||
: jog_phase == JogPhase::Fine ? JogPhase::VeryFine
|
||||
|
|
@ -1510,12 +1513,13 @@ void TuiUi::uiLoop() {
|
|||
// The arrows nudge the gimbal, from any view: left/right = yaw -/+5%,
|
||||
// up/down = pitch -/+10% of travel. Only in manual mode: in auto and pan
|
||||
// the app refuses these anyway, and a silent no-op beats a warning per
|
||||
// keypress from someone leaning on an arrow.
|
||||
// keypress from someone leaning on an arrow. Likewise up/down on a
|
||||
// yaw-only tower.
|
||||
if (manual_mode && sink_) {
|
||||
if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; }
|
||||
if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; }
|
||||
if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); return true; }
|
||||
if (e == Event::ArrowDown) { sink_("gimbal nudge pitch 10"); return true; }
|
||||
if (e == Event::ArrowUp) { if (pitch_present) sink_("gimbal nudge pitch -10"); return true; }
|
||||
if (e == Event::ArrowDown) { if (pitch_present) sink_("gimbal nudge pitch 10"); return true; }
|
||||
}
|
||||
if (!e.is_character()) return false;
|
||||
const std::string& c = e.character();
|
||||
|
|
|
|||
|
|
@ -39,3 +39,39 @@ TEST_CASE("parseCommand on empty input") {
|
|||
CHECK(parseCommand("").empty());
|
||||
CHECK(parseCommand(" ").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("parseAim reads yaw with an optional pitch") {
|
||||
auto tok = [](std::vector<std::string> v) {
|
||||
v.insert(v.begin(), {"gimbal", "move"});
|
||||
return v;
|
||||
};
|
||||
|
||||
auto both = parseAim(tok({"30,-10"}), 2);
|
||||
REQUIRE(both);
|
||||
CHECK(both->yaw == doctest::Approx(30.0));
|
||||
REQUIRE(both->pitch);
|
||||
CHECK(*both->pitch == doctest::Approx(-10.0));
|
||||
|
||||
auto spaced = parseAim(tok({"30", "-10"}), 2);
|
||||
REQUIRE(spaced);
|
||||
REQUIRE(spaced->pitch);
|
||||
CHECK(*spaced->pitch == doctest::Approx(-10.0));
|
||||
CHECK(parseAim(tok({"30,", "-10"}), 2)->pitch.has_value());
|
||||
|
||||
auto yaw_only = parseAim(tok({"90"}), 2);
|
||||
REQUIRE(yaw_only);
|
||||
CHECK(yaw_only->yaw == doctest::Approx(90.0));
|
||||
CHECK_FALSE(yaw_only->pitch);
|
||||
|
||||
CHECK_FALSE(parseAim(tok({}), 2));
|
||||
CHECK_FALSE(parseAim(tok({"abc"}), 2));
|
||||
CHECK_FALSE(parseAim(tok({"30,x"}), 2));
|
||||
CHECK_FALSE(parseAim(tok({"1,2,3"}), 2));
|
||||
}
|
||||
|
||||
TEST_CASE("moveCommand sends both axes with a pitch, yaw alone without") {
|
||||
CHECK(moveCommand(100000, 250000) == "MOVE 100000,250000");
|
||||
CHECK(moveCommand(-5, -7) == "MOVE -5,-7");
|
||||
// The yaw-only form: a 1-axis firmware refuses the pair with "axis not present".
|
||||
CHECK(moveCommand(100000, std::nullopt) == "MOVE Y 100000");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,3 +106,11 @@ TEST_CASE("headingToCounts refuses a degenerate scale") {
|
|||
AxisMap m{0.0, 500000, 0.0, -160.0, 160.0};
|
||||
CHECK_FALSE(m.headingToCounts(90.0).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("encoderPitchDeg falls back to the fixed elevation without a pitch encoder") {
|
||||
AxisMap m{8333.33, 500000, 5.0, -30.0, 30.0};
|
||||
CHECK(encoderPitchDeg(m, 500000) == doctest::Approx(5.0));
|
||||
CHECK(encoderPitchDeg(m, 500000 + 83333) == doctest::Approx(15.0).epsilon(1e-4));
|
||||
// A yaw-only tower: not toDeg(0), which would read about -55 deg here.
|
||||
CHECK(encoderPitchDeg(m, std::nullopt) == doctest::Approx(5.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ TEST_CASE("renderHelp() with no topic lists every section and entry") {
|
|||
|
||||
TEST_CASE("renderHelp(<section>) expands that section with detail") {
|
||||
std::string out = join(renderHelp("positioning"));
|
||||
CHECK(out.find("gimbal move <yaw>,<pitch>") != std::string::npos);
|
||||
CHECK(out.find("gimbal move <yaw>[,<pitch>]") != std::string::npos);
|
||||
// Detail lines (example) are only emitted in topic mode.
|
||||
CHECK(out.find("gimbal move 30,-10") != std::string::npos);
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ TEST_CASE("renderHelp(<section>) expands that section with detail") {
|
|||
TEST_CASE("renderHelp(<verb>) matches the gimbal commands, case-insensitively") {
|
||||
std::string lower = join(renderHelp("gimbal"));
|
||||
std::string upper = join(renderHelp("GIMBAL"));
|
||||
CHECK(lower.find("gimbal move <yaw>,<pitch>") != std::string::npos);
|
||||
CHECK(lower.find("gimbal move <yaw>[,<pitch>]") != std::string::npos);
|
||||
CHECK(lower.find("gimbal pitch setup") != std::string::npos);
|
||||
// The removed `gimbal calib` must not linger anywhere in the catalog.
|
||||
CHECK(lower.find("gimbal calib") == std::string::npos);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ TEST_CASE("parseScanCsv reads waypoints and skips comments/blanks") {
|
|||
CHECK(pts[2].yaw_deg == doctest::Approx(90.0));
|
||||
}
|
||||
|
||||
TEST_CASE("parseScanCsv takes a bare yaw as pitch 0") {
|
||||
auto pts = parseScanCsv("45\n90,10\n");
|
||||
REQUIRE(pts.size() == 2);
|
||||
CHECK(pts[0].yaw_deg == doctest::Approx(45.0));
|
||||
CHECK(pts[0].pitch_deg == doctest::Approx(0.0));
|
||||
CHECK(pts[1].pitch_deg == doctest::Approx(10.0));
|
||||
}
|
||||
|
||||
TEST_CASE("parseScanCsv throws on a malformed line") {
|
||||
CHECK_THROWS(parseScanCsv("0,0\nnonsense\n"));
|
||||
CHECK_THROWS(parseScanCsv("1,abc\n"));
|
||||
|
|
@ -80,3 +88,10 @@ TEST_CASE("ScanGrid single point and empty are safe") {
|
|||
none.advance(); // must not divide by zero or index an empty vector
|
||||
CHECK(none.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("repeatedBearings counts rows that only differ in pitch") {
|
||||
CHECK(repeatedBearings({}) == 0);
|
||||
CHECK(repeatedBearings({{0.0, 10.0}, {90.0, 10.0}}) == 0);
|
||||
CHECK(repeatedBearings({{0.0, 10.0}, {0.0, -10.0}, {90.0, 0.0}, {90.0, 5.0}}) == 2);
|
||||
CHECK(repeatedBearings({{-90.0, 0.0}, {270.0, 0.0}}) == 1); // the same bearing
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,19 @@ TEST_CASE("parseTelemetryLine reads a yaw-only ST line (no flags field)") {
|
|||
CHECK_FALSE(t->pitch_present);
|
||||
}
|
||||
|
||||
TEST_CASE("pitchAbsent: a yaw-only status line, but never before the first one") {
|
||||
MotorTelemetry none; // nothing heard from the firmware yet
|
||||
CHECK_FALSE(none.pitchAbsent());
|
||||
|
||||
auto yaw_only = parseTelemetryLine("ST Y:R,10,12,00000000,0,0,0");
|
||||
REQUIRE(yaw_only);
|
||||
CHECK(yaw_only->pitchAbsent());
|
||||
|
||||
auto both = parseTelemetryLine("ST Y:A,1,1,00000000,0,0,0,S P:A,2,2,00000000,0,0,0,S");
|
||||
REQUIRE(both);
|
||||
CHECK_FALSE(both->pitchAbsent());
|
||||
}
|
||||
|
||||
TEST_CASE("parseTelemetryLine rejects non-ST and malformed lines") {
|
||||
CHECK_FALSE(parseTelemetryLine("").has_value());
|
||||
CHECK_FALSE(parseTelemetryLine("OK").has_value());
|
||||
|
|
|
|||
Loading…
Reference in New Issue