Let a yaw-only tower aim with gimbal move, and stop it publishing a made-up pitch

This commit is contained in:
pgdalmeida 2026-09-29 19:50:39 +02:00
parent 895c2f22fd
commit f2c61e030e
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
25 changed files with 288 additions and 82 deletions

View File

@ -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) |

View File

@ -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).

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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|`

View File

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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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.", {}},

View File

@ -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)));
p.yaw_deg = std::stod(trim(t.substr(0, comma)));
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

View File

@ -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();

View File

@ -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");
}

View File

@ -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));
}

View File

@ -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);

View File

@ -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
}

View File

@ -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());