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) | | `:` | 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) | | `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 | | `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 | | `Space` | take one picture now (`snapshot`) - manual mode only |
| `r` | continuous capture on/off (`capture on` / `capture off`) - 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) | | `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 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° | | `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 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 | | `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
| `snapshot` | take one picture now (manual mode) | | `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) | | `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 pitch_center_count = 500000
; Elevation above level (+ = up) at the centre. This is NOT 0: the mechanical ; 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. ; 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_angle_at_center = 10.0
pitch_soft_margin_counts = 5000 pitch_soft_margin_counts = 5000
; Live encoder-error WARN threshold in counts (0 = off). ; 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 # Edit these to define the exact scan coordinates. Blank lines and lines
# starting with '#' are ignored. This file is the only source of waypoints: # 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 # 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 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. 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: 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: `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`. pan's pitch, converted to counts via `Geometry`.
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the - 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 auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below. capture* below.
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the 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 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 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. `<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose.
@ -97,7 +99,8 @@ corrupted.
▼ ▼
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch) ┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's 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) │ (both axes standstill AND |xenc − target| ≤ tol)
▼ ▼
┌──────────────────────┐ the camera keeps streaming throughout; this pause is ┌──────────────────────┐ 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_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_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` | `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` | `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` | `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 | | `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_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>` | `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** | | `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 ### `[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 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. 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. 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 ### `[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 ### Declared vs found — what `hardware.ini` is checked against
Everything in this program degrades gracefully: `TestRunner` skips the pitch and IMU leaves, `CamEvent` 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 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 tower whose pitch stops enumerating becomes a yaw-only tower, captures one elevation band, and reports
itself healthy. 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 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 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 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* 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. 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 | | `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 | | `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 | | `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 <yaw>,<pitch>` | each capture point; `gimbal move/steps` with a pitch | drive both axes to absolute counts (combined form) |
| `MOVE Y\|P <pos>` | — | single-axis absolute move (`gimbal raw`) | | `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 | | `STOP Y\|P\|ALL` | — | ramp to a controlled stop |
### Motor current, and why the host computes it ### Motor current, and why the host computes it

View File

@ -359,8 +359,8 @@ This lands in `updateIniSectionKeys` + `saveMotorGeometry`, and `saveCalibration
## 10. Declaring presence ## 10. Declaring presence
The existing code degrades gracefully everywhere — `TestRunner` skips the pitch and IMU leaves, CamEvent 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 falls back to encoder pitch (or the fixed `pitch_angle_at_center` with no pitch axis), `HeadingFit` is
`MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a 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 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. 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/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/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/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/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) + one-way wrapping cursor (`[Scan]`) | | [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/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/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/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 | | [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` 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 (`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. `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`. 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|` `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 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 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 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 (`image_interval`), and each publishes a CamEvent exactly as a sweep picture
does. does.
@ -96,7 +96,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
| `fwt` | string | Tower name (`config.ini` `tower_name`) | | `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | | `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** | | `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) | | `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 > 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 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. 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 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 finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
pixels. 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 home # the endstop-finding sequence - see the warning below
gimbal diag y # firmware self-test, reports PASS/FAIL 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:** **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 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 ## 10b. Bringing up the config handshake on an existing tower

View File

@ -1,6 +1,8 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector>
namespace fgc { namespace fgc {
@ -25,4 +27,18 @@ struct Command {
// Replaces the fragile Boost.Spirit Qi grammar. // Replaces the fragile Boost.Spirit Qi grammar.
Command parseCommand(const std::string& line); 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 } // namespace fgc

View File

@ -50,6 +50,10 @@ struct AxisMap {
std::optional<long> headingToCounts(double heading_deg) const; 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. // Per-axis maps for the two-axis gimbal.
struct Geometry { struct Geometry {
AxisMap yaw; AxisMap yaw;

View File

@ -58,6 +58,10 @@ struct MotorTelemetry {
AxisTelemetry yaw; AxisTelemetry yaw;
AxisTelemetry pitch; AxisTelemetry pitch;
bool pitch_present = false; // was a P: segment seen? 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 // Abstraction over the gimbal's motor controller. Implemented by

View File

@ -53,4 +53,8 @@ private:
// Exposed for unit testing. // Exposed for unit testing.
std::vector<ScanPoint> parseScanCsv(const std::string& text); 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 } // 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. // without this the only symptom would be a grid quietly shorter than the CSV.
void auditScanReachability() { void auditScanReachability() {
if (grid.empty()) return; 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; std::size_t bad = 0;
for (const auto& p : grid.points()) for (const auto& p : grid.points())
if (!cfg.geometry.yaw.headingToCounts(p.yaw_deg)) ++bad; if (!cfg.geometry.yaw.headingToCounts(p.yaw_deg)) ++bad;
@ -1803,8 +1809,7 @@ struct Application::Impl {
scheduler->setMode(m, pan); scheduler->setMode(m, pan);
if (m == Mode::Pan) { if (m == Mode::Pan) {
const auto& h = scheduler->panHeadingsNow(); const auto& h = scheduler->panHeadingsNow();
LOG_INFO << "mode pan: heading " << pan.heading << ", span " << pan.span LOG_INFO << "mode pan: " << panText(pan) << " - " << h.size()
<< ", pitch " << pan.pitch << " deg - " << h.size()
<< (h.size() == 1 ? " position" : " positions") << " (" << (h.size() == 1 ? " position" : " positions") << " ("
<< panRangeText(h) << ")"; << panRangeText(h) << ")";
} else { } else {
@ -1818,6 +1823,9 @@ struct Application::Impl {
// clamped to it - that would image an elevation nobody asked for), and at // clamped to it - that would image an elevation nobody asked for), and at
// least one of its headings inside the reachable yaw arc. // least one of its headings inside the reachable yaw arc.
bool checkPan(const PanSpec& pan) { 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; const AxisMap& p = cfg.geometry.pitch;
if (cfg.hardware.hasAxis("pitch") && (pan.pitch < p.min_deg || pan.pitch > p.max_deg)) { 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 " LOG_WARN << "mode pan refused: pitch " << pan.pitch << " deg is outside the homed "
@ -1840,6 +1848,15 @@ struct Application::Impl {
return true; 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) { static std::string panRangeText(const std::vector<double>& h) {
if (h.empty()) return "none"; if (h.empty()) return "none";
std::ostringstream o; std::ostringstream o;
@ -1867,8 +1884,7 @@ struct Application::Impl {
if (m == Mode::Pan) { if (m == Mode::Pan) {
const PanSpec p = scheduler->pan(); const PanSpec p = scheduler->pan();
const auto& h = scheduler->panHeadingsNow(); const auto& h = scheduler->panHeadingsNow();
LOG_INFO << "mode pan: heading " << p.heading << ", span " << p.span << ", pitch " LOG_INFO << "mode pan: " << panText(p) << " - " << h.size()
<< p.pitch << " deg - " << h.size()
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h) << (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size(); << "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
} else { } else {
@ -2086,6 +2102,28 @@ struct Application::Impl {
return false; 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 // `trace off|none`, `trace all`, or `trace <cat> [on|off]` — toggle the
// verbatim wire-trace categories (serial/mqtt/camera/control). // verbatim wire-trace categories (serial/mqtt/camera/control).
void handleTrace(const Command& c) { void handleTrace(const Command& c) {
@ -2107,20 +2145,6 @@ struct Application::Impl {
LOG_INFO << "trace categories: " << traceNames(Logger::categories()); 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 // Cross-check the degree scale against the travel homing just measured, and
// bootstrap it when it is obviously wrong. // bootstrap it when it is obviously wrong.
// //
@ -2222,26 +2246,37 @@ struct Application::Impl {
} }
if (sub == "move") { if (sub == "move") {
double yaw_deg = 0, pitch_deg = 0; auto aim = parseAim(tok, 2);
if (!parsePair(tok, 2, yaw_deg, pitch_deg)) { if (!aim) {
LOG_WARN << "usage: gimbal move <yaw_deg>,<pitch_deg>"; LOG_WARN << "usage: gimbal move <yaw_deg>[,<pitch_deg>]";
return; return;
} }
long yc = cfg.geometry.yaw.toCounts(yaw_deg); // Yaw is a true bearing: one in the arc the gimbal cannot reach is
long pc = cfg.geometry.pitch.toCounts(pitch_deg); // refused, as a pan's are - clamping would image the wrong direction.
LOG_INFO << "gimbal move yaw=" << yaw_deg << " pitch=" << pitch_deg auto yc = cfg.geometry.yaw.headingToCounts(aim->yaw);
<< " deg -> MOVE " << yc << "," << pc; if (!yc) {
motor->sendCommand("MOVE " + std::to_string(yc) + "," + std::to_string(pc)); 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") { } else if (sub == "steps") {
double yc = 0, pc = 0; auto aim = parseAim(tok, 2);
if (!parsePair(tok, 2, yc, pc)) { if (!aim) {
LOG_WARN << "usage: gimbal steps <yaw_counts>,<pitch_counts>"; LOG_WARN << "usage: gimbal steps <yaw_counts>[,<pitch_counts>]";
return; 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") { } else if (sub == "nudge") {
if (tok.size() < 4) { LOG_WARN << "usage: gimbal nudge <yaw|pitch> <+/-pct>"; return; } 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'; char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
if (axis == 'P' && !requirePitch("gimbal nudge pitch")) return;
double pct = 0; double pct = 0;
try { pct = std::stod(tok[3]); } catch (...) { LOG_WARN << "gimbal nudge: bad percent"; return; } 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 // 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. // heading wizard needs, and counts are what the encoder resolves.
if (tok.size() < 4) { LOG_WARN << "usage: gimbal jog <yaw|pitch> <+/-counts>"; return; } 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'; char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
if (axis == 'P' && !requirePitch("gimbal jog pitch")) return;
long delta = 0; long delta = 0;
try { delta = std::stol(tok[3]); } catch (...) { LOG_WARN << "gimbal jog: bad count"; return; } try { delta = std::stol(tok[3]); } catch (...) { LOG_WARN << "gimbal jog: bad count"; return; }
MotorTelemetry tel = motor->telemetry(); MotorTelemetry tel = motor->telemetry();
@ -2309,6 +2345,9 @@ struct Application::Impl {
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "disable" || sub == "speed" || sub == "setpos" ||
sub == "status" || sub == "current") { sub == "status" || sub == "current") {
// Passthrough to the (case-insensitive) firmware verb + args. // 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; std::string fw = sub;
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch)); 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]; 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 // 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 - // gravity, and nothing else on the tower can measure it. Yaw does not -
// `gimbal heading setup` uses landmarks instead. // `gimbal heading setup` uses landmarks instead.
if (!requirePitch("gimbal pitch setup")) return;
if (!imu) { if (!imu) {
LOG_WARN << "gimbal pitch setup requires the IMU (declare [Hardware] imu); " LOG_WARN << "gimbal pitch setup requires the IMU (declare [Hardware] imu); "
"yaw does not - use 'gimbal heading setup'"; "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 // 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 // gimbal's own base, so a leaning mast is invisible to it; the
// IMU measures against gravity, which is the true elevation 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>( pipeline = std::make_unique<ImagePipeline>(
*channel, *channel,
[this] { [this] {
MotorTelemetry t = motor->telemetry(); 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)), 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 (!imu) return o; // IMU disabled in config: encoder pitch, as before
if (auto s = imu->sample()) { if (auto s = imu->sample()) {
o.pitch_deg = s->pitch_deg; o.pitch_deg = s->pitch_deg;

View File

@ -1,5 +1,6 @@
#include "fgc/CaptureScheduler.h" #include "fgc/CaptureScheduler.h"
#include "fgc/CommandParser.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
@ -72,12 +73,8 @@ void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
void CaptureScheduler::sendMove() { void CaptureScheduler::sendMove() {
// On a 1-axis (yaw-only) rig the firmware rejects the two-axis `MOVE y,p` // 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. // form ("ERR axis not present") and moves nothing, so address yaw alone.
if (pitch_present_) { motor_.sendCommand(moveCommand(yaw_target_, pitch_present_ ? std::optional<long>(pitch_target_)
motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," + : std::nullopt));
std::to_string(pitch_target_));
} else {
motor_.sendCommand("MOVE Y " + std::to_string(yaw_target_));
}
} }
bool CaptureScheduler::settledAt(const MotorTelemetry& t) const { bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {

View File

@ -53,4 +53,28 @@ Command parseCommand(const std::string& line) {
return cmd; 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 } // namespace fgc

View File

@ -46,4 +46,8 @@ std::optional<long> AxisMap::headingToCounts(double heading_deg) const {
return toCounts(deg); return toCounts(deg);
} }
double encoderPitchDeg(const AxisMap& pitch, std::optional<long> counts) {
return counts ? pitch.toDeg(*counts) : pitch.deg_at_center;
}
} // namespace fgc } // namespace fgc

View File

@ -31,7 +31,8 @@ const std::vector<HelpSection>& helpCatalog() {
"status, logging, 'mode', 'cancel' and 'exit' are accepted."}}, "status, logging, 'mode', 'cancel' and 'exit' are accepted."}},
{"mode pan <heading> <span> [<pitch>]", "Sweep a span centred on a bearing, repeatedly (key p).", { {"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.", "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"}}, "Example: mode pan 90 30"}},
{"mode manual", "Stop scanning; aim by hand (key m). Cancels a running procedure.", {}}, {"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."}}, "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).", { {"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.", "Bearings in the unreachable yaw arc are refused, not clamped.",
"Example: gimbal move 30,-10"}}, "Without a pitch, only yaw moves. A yaw-only tower ignores the pitch",
{"gimbal steps <yaw>,<pitch>", "Move to absolute encoder counts.", { "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"}}, "Example: gimbal steps 100000,250000"}},
{"gimbal nudge <yaw|pitch> <+/-pct>", "Relative move, percent of travel (arrow keys).", { {"gimbal nudge <yaw|pitch> <+/-pct>", "Relative move, percent of travel (arrow keys).", {
"Example: gimbal nudge yaw -5"}}, "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.", {}}, {"a / p / m", "mode auto / mode pan (30 deg around the aim) / mode manual.", {}},
{"h", "gimbal home.", {}}, {"h", "gimbal home.", {}},
{"Space / r", "snapshot / capture on|off.", {}}, {"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.", {}}, {"Esc", "Close the view, or cancel a running procedure.", {}},
{"? / g / s / c / n", "Views: help, gimbal, sensors, cameras, connectivity.", {}}, {"? / g / s / c / n", "Views: help, gimbal, sensors, cameras, connectivity.", {}},
{"PgUp / PgDn", "Scroll the log or the open view.", {}}, {"PgUp / PgDn", "Scroll the log or the open view.", {}},

View File

@ -1,6 +1,9 @@
#include "fgc/ScanGrid.h" #include "fgc/ScanGrid.h"
#include "fgc/Geometry.h"
#include <fstream> #include <fstream>
#include <set>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@ -27,14 +30,12 @@ std::vector<ScanPoint> parseScanCsv(const std::string& text) {
++lineno; ++lineno;
std::string t = trim(line); std::string t = trim(line);
if (t.empty() || t[0] == '#') continue; 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(','); 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 { try {
ScanPoint p; ScanPoint p;
p.yaw_deg = std::stod(trim(t.substr(0, comma))); 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); pts.push_back(p);
} catch (const std::exception&) { } catch (const std::exception&) {
throw std::runtime_error("scan grid line " + std::to_string(lineno) + 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; 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) { ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) {
// No grid_file, no grid. Generating a default sweep here would put a tower // No grid_file, no grid. Generating a default sweep here would put a tower
// on the air photographing bearings nobody picked, under a configuration // 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 // flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui"). // than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
Element keyBar(Overlay overlay, bool manual, bool heading_setup) { Element keyBar(Overlay overlay, bool manual, bool heading_setup, bool pitch_present) {
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; // ←→↑↓, 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; Elements e;
if (heading_setup) { if (heading_setup) {
// Replaces the common set: mode changes are refused during heading // Replaces the common set: mode changes are refused during heading
// setup, so advertising them here would only mislead. // 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("Tab", "Step"));
e.push_back(keyHint("Enter", "Mark")); e.push_back(keyHint("Enter", "Mark"));
e.push_back(keyHint("Spc", "Snap")); 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 // Hand aiming is refused outside manual, so stop advertising it rather
// than inviting a keypress that only warns. // than inviting a keypress that only warns.
if (manual) { if (manual) {
e.push_back(keyHint(kArrows4, "Nudge")); e.push_back(keyHint(arrows, "Nudge"));
e.push_back(keyHint("Spc", "Snap")); e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("r", "Capture")); e.push_back(keyHint("r", "Capture"));
} }
@ -1352,7 +1354,8 @@ void TuiUi::uiLoop() {
Element bottom = command_mode Element bottom = command_mode
? hbox({text(" : ") | inverted, input->Render() | flex}) | border ? hbox({text(" : ") | inverted, input->Render() | flex}) | border
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX"))) : exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
: keyBar(overlay, s.capture.manual, 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 // An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the // hidden) so it has the full height — otherwise tall content like the
@ -1459,9 +1462,9 @@ void TuiUi::uiLoop() {
if (e == Event::ArrowLeft) { jog("yaw", yaw_travel, -1); return true; } if (e == Event::ArrowLeft) { jog("yaw", yaw_travel, -1); return true; }
if (e == Event::ArrowRight) { 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 // Pitch too: the landmark has to be in frame vertically before its
// bearing means anything. // bearing means anything. A yaw-only tower has nothing to send.
if (e == Event::ArrowUp) { jog("pitch", pitch_travel, +1); return true; } if (e == Event::ArrowUp) { if (pitch_present) jog("pitch", pitch_travel, +1); return true; }
if (e == Event::ArrowDown) { 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) { if (e == Event::Tab) {
jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine
: jog_phase == JogPhase::Fine ? JogPhase::VeryFine : 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%, // 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 // 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 // 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 (manual_mode && sink_) {
if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; } if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; }
if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; } if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; }
if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); return true; } if (e == Event::ArrowUp) { if (pitch_present) sink_("gimbal nudge pitch -10"); return true; }
if (e == Event::ArrowDown) { 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; if (!e.is_character()) return false;
const std::string& c = e.character(); const std::string& c = e.character();

View File

@ -39,3 +39,39 @@ TEST_CASE("parseCommand on empty input") {
CHECK(parseCommand("").empty()); CHECK(parseCommand("").empty());
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}; AxisMap m{0.0, 500000, 0.0, -160.0, 160.0};
CHECK_FALSE(m.headingToCounts(90.0).has_value()); 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") { TEST_CASE("renderHelp(<section>) expands that section with detail") {
std::string out = join(renderHelp("positioning")); 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. // Detail lines (example) are only emitted in topic mode.
CHECK(out.find("gimbal move 30,-10") != std::string::npos); 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") { TEST_CASE("renderHelp(<verb>) matches the gimbal commands, case-insensitively") {
std::string lower = join(renderHelp("gimbal")); std::string lower = join(renderHelp("gimbal"));
std::string upper = 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); CHECK(lower.find("gimbal pitch setup") != std::string::npos);
// The removed `gimbal calib` must not linger anywhere in the catalog. // The removed `gimbal calib` must not linger anywhere in the catalog.
CHECK(lower.find("gimbal calib") == std::string::npos); 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)); 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") { TEST_CASE("parseScanCsv throws on a malformed line") {
CHECK_THROWS(parseScanCsv("0,0\nnonsense\n")); CHECK_THROWS(parseScanCsv("0,0\nnonsense\n"));
CHECK_THROWS(parseScanCsv("1,abc\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 none.advance(); // must not divide by zero or index an empty vector
CHECK(none.empty()); 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); 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") { TEST_CASE("parseTelemetryLine rejects non-ST and malformed lines") {
CHECK_FALSE(parseTelemetryLine("").has_value()); CHECK_FALSE(parseTelemetryLine("").has_value());
CHECK_FALSE(parseTelemetryLine("OK").has_value()); CHECK_FALSE(parseTelemetryLine("OK").has_value());