Answer the firmware's NEED lines from hardware.ini
This commit is contained in:
parent
579c4ec166
commit
522ba072ec
|
|
@ -67,6 +67,7 @@ add_library(fgc_core STATIC
|
||||||
src/core/HardwarePresence.cpp
|
src/core/HardwarePresence.cpp
|
||||||
src/core/HardwareChecks.cpp
|
src/core/HardwareChecks.cpp
|
||||||
src/core/AxisSpeed.cpp
|
src/core/AxisSpeed.cpp
|
||||||
|
src/core/FirmwareConfig.cpp
|
||||||
src/core/ScanGrid.cpp
|
src/core/ScanGrid.cpp
|
||||||
src/core/TelemetryParser.cpp
|
src/core/TelemetryParser.cpp
|
||||||
src/core/CaptureScheduler.cpp
|
src/core/CaptureScheduler.cpp
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,11 @@ mock_camera = false
|
||||||
mock_serial = false
|
mock_serial = false
|
||||||
mock_imu = false
|
mock_imu = false
|
||||||
mock_env = false
|
mock_env = false
|
||||||
|
; Make the simulated motor controller run the config handshake: emit NEED lines
|
||||||
|
; at startup and reject HOME/MOVE/ENABLE until every requested key is answered
|
||||||
|
; from hardware.ini. How the host half is exercised before a firmware that asks
|
||||||
|
; exists - and afterwards, on a dev box with no rig.
|
||||||
|
mock_needcfg = false
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
; Hardware self-test command (`test`). `profile` is the default profile used when
|
; Hardware self-test command (`test`). `profile` is the default profile used when
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,32 @@ travel_deg = 340
|
||||||
; so this follows the calibration automatically. An axis whose scale is not
|
; so this follows the calibration automatically. An axis whose scale is not
|
||||||
; trustworthy is left on the firmware's default and the reason is logged.
|
; trustworthy is left on the firmware's default and the reason is logged.
|
||||||
scan_speed_deg_s = 8.0
|
scan_speed_deg_s = 8.0
|
||||||
|
; The other two motions, also in deg/s. Both happen BEFORE the first home, so
|
||||||
|
; they cannot use the counts_per_deg that homing derives - they convert through
|
||||||
|
; the scale this file implies (steps_per_rev / travel_deg), which on the IOTA
|
||||||
|
; agrees with the measured one to 0.25%.
|
||||||
|
homing_speed_deg_s = 2.0
|
||||||
|
center_speed_deg_s = 6.0
|
||||||
|
|
||||||
|
; --- Told to the firmware, which holds none of it itself --------------------
|
||||||
|
; The config handshake (docs/configuration.md): the firmware asks with NEED and
|
||||||
|
; these answer it. A key missing here leaves that axis in NEEDCFG, refusing to
|
||||||
|
; move, with the missing name logged - deliberately, because a drivetrain value
|
||||||
|
; guessed by the firmware is the duplication this removes.
|
||||||
|
;
|
||||||
|
; Expected full mechanical travel, IN COUNTS. Measured, not derived: it is the
|
||||||
|
; only guard against a PHANTOM endstop - too little homing current and the motor
|
||||||
|
; stalls on ordinary friction, trips the same latch a real stop does, and the
|
||||||
|
; bogus limit is accepted.
|
||||||
|
steps_per_rev = 1210778
|
||||||
|
; |XACTUAL - XENC| that means "position untrustworthy". Re-measure whenever the
|
||||||
|
; homing current changes: the free-running gap is coupled to it.
|
||||||
|
enc_deviation = 1500
|
||||||
|
; TMC SW_MODE - physical endstop switches on REFL/REFR. Yaw on this rig homes by
|
||||||
|
; StallGuard with no switches (0); an axis with real switches sets the bits.
|
||||||
|
sw_mode = 0
|
||||||
|
; Is this axis's encoder aligned to XACTUAL and usable for hold and DIAG?
|
||||||
|
has_encoder = true
|
||||||
|
|
||||||
[Camera.rgb]
|
[Camera.rgb]
|
||||||
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
|
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi |
|
| `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi |
|
||||||
| `Features` | `enable_env` | bool | `false` | **Legacy.** Derived from `[Hardware] env` wherever that is declared; same rule as `enable_imu` |
|
| `Features` | `enable_env` | bool | `false` | **Legacy.** Derived from `[Hardware] env` wherever that is declared; same rule as `enable_imu` |
|
||||||
| `Features` | `mock_env` | bool | `false` | Use the simulated env sensor instead of the SHT41 |
|
| `Features` | `mock_env` | bool | `false` | Use the simulated env sensor instead of the SHT41 |
|
||||||
|
| `Features` | `mock_needcfg` | bool | `false` | Make the simulated motor controller run the config handshake (emit `NEED`, refuse to move until answered) |
|
||||||
| `Features` | `strict_presence` | bool | `false` | Refuse to start when `hardware.ini` declares a subsystem this rig does not have. Off during the fleet rollout; turn it on per tower once that file is known good. `--degraded` overrides it |
|
| `Features` | `strict_presence` | bool | `false` | Refuse to start when `hardware.ini` declares a subsystem this rig does not have. Off during the fleet rollout; turn it on per tower once that file is known good. `--degraded` overrides it |
|
||||||
| `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
|
| `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
|
||||||
| `IMU` | `baud` | int | `115200` | MTi serial baud rate |
|
| `IMU` | `baud` | int | `115200` | MTi serial baud rate |
|
||||||
|
|
@ -75,6 +76,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `Motor.<axis>` | `run_current_ma` / `homing_current_ma` | float | — | Coil current (mA RMS) for normal motion, and while hunting an endstop. **hardware.ini** |
|
| `Motor.<axis>` | `run_current_ma` / `homing_current_ma` | float | — | Coil current (mA RMS) for normal motion, and while hunting an endstop. **hardware.ini** |
|
||||||
| `Motor.<axis>` | `travel_deg` | float | — | Mechanical angle between the hard endstops. **hardware.ini.** Homing divides the measured travel by it to derive `<axis>_counts_per_deg` |
|
| `Motor.<axis>` | `travel_deg` | float | — | Mechanical angle between the hard endstops. **hardware.ini.** Homing divides the measured travel by it to derive `<axis>_counts_per_deg` |
|
||||||
| `Motor.<axis>` | `scan_speed_deg_s` | float | `8.0` | Production move speed **in degrees per second**. **hardware.ini.** The host converts it to VMAX through `<axis>_counts_per_deg` and sends `SPEED`; a register value would not mean the same thing on two drivetrains |
|
| `Motor.<axis>` | `scan_speed_deg_s` | float | `8.0` | Production move speed **in degrees per second**. **hardware.ini.** The host converts it to VMAX through `<axis>_counts_per_deg` and sends `SPEED`; a register value would not mean the same thing on two drivetrains |
|
||||||
|
| `Motor.<axis>` | `homing_speed_deg_s` / `center_speed_deg_s` | float | — | Endstop-search and park speeds, also in °/s. **hardware.ini.** Both are needed *before* the first home, so they convert through the declared scale (`steps_per_rev / travel_deg`), not the homed one |
|
||||||
|
| `Motor.<axis>` | `steps_per_rev` | int | — | Expected full mechanical travel, **in counts**. **hardware.ini.** The firmware's only guard against a phantom endstop |
|
||||||
|
| `Motor.<axis>` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **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; empty → generate |
|
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate |
|
||||||
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
|
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
|
||||||
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
|
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
|
||||||
|
|
@ -684,6 +689,46 @@ host checks them **before** homing. If an axis declares a reduced homing current
|
||||||
firmware has not adopted it, the host **refuses to home** rather than driving into a stop at
|
firmware has not adopted it, the host **refuses to home** rather than driving into a stop at
|
||||||
full torque — the fire it is there to prevent.
|
full torque — the fire it is there to prevent.
|
||||||
|
|
||||||
|
### The config handshake — the firmware asks, `hardware.ini` answers
|
||||||
|
|
||||||
|
Today the firmware carries a full parameter table and **guesses** when the host
|
||||||
|
says nothing, so every drivetrain value exists in two places and the two can
|
||||||
|
disagree. Section 6 of the config plan replaces that: the firmware declares what
|
||||||
|
it needs and refuses to move until it has it.
|
||||||
|
|
||||||
|
```
|
||||||
|
firmware -> BOOT build=abc123
|
||||||
|
firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed ...
|
||||||
|
host -> SET Y steps_per_rev 1210778
|
||||||
|
host -> SET Y global_scaler 80
|
||||||
|
host -> SET Y normal_speed 39830
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`SPEED`, `CENTER`, `MARGIN` and `CURRENT` remain as aliases, so an old firmware
|
||||||
|
and a hand-typed session both keep working; `center` and `margin` are accepted as
|
||||||
|
key names alongside `center_count` and `soft_margin` for the same reason.
|
||||||
|
|
||||||
|
Three rules, each protecting against a different silent failure:
|
||||||
|
|
||||||
|
- **A value the host does not have is left unanswered, never sent as zero.** A
|
||||||
|
zero here is a real setting — a zeroed current is a motor that cannot hold, a
|
||||||
|
zeroed VMAX is a stopped axis. The axis stays in `NEEDCFG` and the missing keys
|
||||||
|
are named at `ERROR`, because nothing but editing `hardware.ini` will make the
|
||||||
|
gimbal move.
|
||||||
|
- **A key the host has never heard of is reported separately** from one it simply
|
||||||
|
lacks. The first means the firmware is newer than the host and the fix is to
|
||||||
|
upgrade the host; the second means `hardware.ini` is incomplete. Conflating them
|
||||||
|
sends someone to edit the wrong file.
|
||||||
|
- **A partial answer does not unlock the axis.** Most keys landing is the common
|
||||||
|
case when a declaration is nearly complete, and it must not look like success.
|
||||||
|
|
||||||
|
> **Status: the host half only.** The host answers `NEED` and the responder is
|
||||||
|
> verified end to end, but the firmware that *asks* is not written yet. Set
|
||||||
|
> `[Features] mock_needcfg = 1` to make the simulated controller run the
|
||||||
|
> handshake — emit `NEED` at start, reject `HOME` / `MOVE` / `ENABLE` until every
|
||||||
|
> requested key is answered — which is how this is exercised on a dev box.
|
||||||
|
|
||||||
### Why speed is declared in degrees per second
|
### Why speed is declared in degrees per second
|
||||||
|
|
||||||
`VMAX` is a register value, and what it *means* depends on the gear ratio:
|
`VMAX` is a register value, and what it *means* depends on the gear ratio:
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,7 @@ Phase 1 is deployable on its own. Phase 2 requires it.
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
|
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
|
||||||
273 tests pass, up from a 204 baseline.
|
286 tests pass, up from a 204 baseline.
|
||||||
|
|
||||||
| § | Item | State |
|
| § | Item | State |
|
||||||
|----|------|-------|
|
|----|------|-------|
|
||||||
|
|
@ -438,6 +438,7 @@ Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-confi
|
||||||
| 10 | Presence reconciliation (declared vs found) | **done**, 13 tests. `HardwarePresence.h` compares `[Hardware]` against what came up; warn-only by default, refusing under `[Features] strict_presence` with `--degraded` as the field-triage escape (decision 4) |
|
| 10 | Presence reconciliation (declared vs found) | **done**, 13 tests. `HardwarePresence.h` compares `[Hardware]` against what came up; warn-only by default, refusing under `[Features] strict_presence` with `--degraded` as the field-triage escape (decision 4) |
|
||||||
| 11 | `test hardware` | **done**, 11 tests. Five leaves (`axes` / `motor` / `imu` / `env` / `camera`) under a new `hardware` subsystem; included in a bare `test`, and deliberately not gated on a homed gimbal. Nothing declared reports SKIP, never PASS |
|
| 11 | `test hardware` | **done**, 11 tests. Five leaves (`axes` / `motor` / `imu` / `env` / `camera`) under a new `hardware` subsystem; included in a bare `test`, and deliberately not gated on a homed gimbal. Nothing declared reports SKIP, never PASS |
|
||||||
| 5 | Delete the `Application.cpp` speed literals | **done**, 13 tests. Decision 2 answered: speed is declared as `[Motor.<axis>] scan_speed_deg_s` (default **8.0 deg/s**) and converted per tower through `counts_per_deg` (`AxisSpeed.h`). The literals sent 36.4 deg/s against an intended 21.8 |
|
| 5 | Delete the `Application.cpp` speed literals | **done**, 13 tests. Decision 2 answered: speed is declared as `[Motor.<axis>] scan_speed_deg_s` (default **8.0 deg/s**) and converted per tower through `counts_per_deg` (`AxisSpeed.h`). The literals sent 36.4 deg/s against an intended 21.8 |
|
||||||
|
| 6 | Config handshake — **host half** | **done**, 13 tests. `FirmwareConfig.h` parses `NEED` and plans the `SET` lines; unanswered keys and unknown keys are reported separately. `[Features] mock_needcfg` makes the simulated controller run the handshake, so the host half is verified with no firmware. **The firmware half is not written** |
|
||||||
|
|
||||||
Presence reconciliation (§10) changes nothing on a tower without a `hardware.ini`: absent is
|
Presence reconciliation (§10) changes nothing on a tower without a `hardware.ini`: absent is
|
||||||
silent and `hardware.declared` stays false. Verified by running the binary both ways.
|
silent and `hardware.declared` stays false. Verified by running the binary both ways.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
||||||
| [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 |
|
||||||
| [include/fgc/HardwareChecks.h](../include/fgc/HardwareChecks.h), [src/core/HardwareChecks.cpp](../src/core/HardwareChecks.cpp) | The verdicts behind `test hardware`: `checkUsbfsMemory`, `checkUsb3Lpm`, `checkI2cFunctionality`, `checkDrivetrainAdopted`. Pure, so the thresholds that encode known-issues #2 and #19 cannot drift unnoticed |
|
| [include/fgc/HardwareChecks.h](../include/fgc/HardwareChecks.h), [src/core/HardwareChecks.cpp](../src/core/HardwareChecks.cpp) | The verdicts behind `test hardware`: `checkUsbfsMemory`, `checkUsb3Lpm`, `checkI2cFunctionality`, `checkDrivetrainAdopted`. Pure, so the thresholds that encode known-issues #2 and #19 cannot drift unnoticed |
|
||||||
| [include/fgc/AxisSpeed.h](../include/fgc/AxisSpeed.h), [src/core/AxisSpeed.cpp](../src/core/AxisSpeed.cpp) | `planAxisSpeed` (deg/s -> TMC `VMAX` through `counts_per_deg`) + `vmaxToDegPerSec`. Pure arithmetic; refuses an uncalibrated scale rather than commanding a near-zero speed |
|
| [include/fgc/AxisSpeed.h](../include/fgc/AxisSpeed.h), [src/core/AxisSpeed.cpp](../src/core/AxisSpeed.cpp) | `planAxisSpeed` (deg/s -> TMC `VMAX` through `counts_per_deg`) + `vmaxToDegPerSec`. Pure arithmetic; refuses an uncalibrated scale rather than commanding a near-zero speed |
|
||||||
|
| [include/fgc/FirmwareConfig.h](../include/fgc/FirmwareConfig.h), [src/core/FirmwareConfig.cpp](../src/core/FirmwareConfig.cpp) | `parseNeedLine` (firmware `NEED` line -> axis + keys) + `planFirmwareConfig` (-> the `SET` lines that answer it). Reports a key it cannot answer separately from one it does not recognise: incomplete `hardware.ini` vs. a firmware newer than the host |
|
||||||
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` |
|
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` |
|
||||||
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
|
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
|
||||||
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
|
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,33 @@ struct AxisDrivetrainConfig {
|
||||||
// The host converts through counts_per_deg and sends SPEED; see AxisSpeed.h.
|
// The host converts through counts_per_deg and sends SPEED; see AxisSpeed.h.
|
||||||
double scan_speed_deg_s = 8.0; // kDefaultScanSpeedDegPerSec
|
double scan_speed_deg_s = 8.0; // kDefaultScanSpeedDegPerSec
|
||||||
|
|
||||||
|
// Speeds for the two motions that are not production moves. Both are also
|
||||||
|
// deg/s, and both are needed BEFORE the first home - so they cannot use the
|
||||||
|
// counts_per_deg that homing derives. preHomeCountsPerDeg() below is the
|
||||||
|
// estimate they convert through.
|
||||||
|
double homing_speed_deg_s = 0.0; // 0 = undeclared -> firmware default stands
|
||||||
|
double center_speed_deg_s = 0.0;
|
||||||
|
|
||||||
|
// The mechanism's own facts, for the config handshake (FirmwareConfig.h).
|
||||||
|
// The firmware holds none of these once it asks for them.
|
||||||
|
long steps_per_rev = 0; // expected full mechanical travel, in counts
|
||||||
|
long enc_deviation = 0; // |XACTUAL-XENC| that means "position untrustworthy"
|
||||||
|
|
||||||
|
// Wiring: TMC SW_MODE (physical endstop switches on REFL/REFR), and whether
|
||||||
|
// this axis's encoder is aligned to XACTUAL and usable for hold and DIAG.
|
||||||
|
bool wiring_declared = false; // any of the two keys below present
|
||||||
|
unsigned sw_mode = 0;
|
||||||
|
bool has_encoder = true;
|
||||||
|
|
||||||
|
// counts_per_deg as the DECLARATION implies it, for use before the axis has
|
||||||
|
// been homed. Homing supersedes it by measuring the travel for real; on the
|
||||||
|
// IOTA the two agree to 0.25% (1210778/340 = 3561 against a measured
|
||||||
|
// 3552.87). Returns 0 when it cannot be formed.
|
||||||
|
double preHomeCountsPerDeg() const {
|
||||||
|
if (steps_per_rev <= 0 || travel_deg <= 0.0) return 0.0;
|
||||||
|
return static_cast<double>(steps_per_rev) / travel_deg;
|
||||||
|
}
|
||||||
|
|
||||||
// True when the homing current is genuinely lower than the run current, i.e.
|
// True when the homing current is genuinely lower than the run current, i.e.
|
||||||
// when the reduction is load-bearing and homing without it risks the rig.
|
// when the reduction is load-bearing and homing without it risks the rig.
|
||||||
bool reducesForHoming() const {
|
bool reducesForHoming() const {
|
||||||
|
|
@ -181,6 +208,10 @@ struct FeaturesConfig {
|
||||||
bool mock_imu = false; // use a simulated IMU instead of the MTi
|
bool mock_imu = false; // use a simulated IMU instead of the MTi
|
||||||
bool enable_env = false; // ambient temp/humidity sensor (off by default)
|
bool enable_env = false; // ambient temp/humidity sensor (off by default)
|
||||||
bool mock_env = false; // use a simulated env sensor instead of the SHT41
|
bool mock_env = false; // use a simulated env sensor instead of the SHT41
|
||||||
|
// Make the simulated motor controller run the config handshake: emit NEED
|
||||||
|
// lines at start and reject motion until they are answered. This is how the
|
||||||
|
// host half is exercised on a dev box with no firmware that asks yet.
|
||||||
|
bool mock_needcfg = false;
|
||||||
|
|
||||||
// Refuse to start when hardware.ini declares a subsystem this rig does not
|
// Refuse to start when hardware.ini declares a subsystem this rig does not
|
||||||
// have (HardwarePresence.h). Off by default so that gaining a hardware.ini
|
// have (HardwarePresence.h). Off by default so that gaining a hardware.ini
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
// The host half of the config handshake (hardware-config-plan.md section 6).
|
||||||
|
//
|
||||||
|
// Today the firmware carries a full parameter table and GUESSES when the host
|
||||||
|
// says nothing, so the same drivetrain value exists in two places and they can
|
||||||
|
// disagree. Instead the firmware declares what it needs and refuses to move
|
||||||
|
// until it has it:
|
||||||
|
//
|
||||||
|
// firmware -> BOOT build=abc123
|
||||||
|
// firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation ...
|
||||||
|
// host -> SET Y steps_per_rev 1210778
|
||||||
|
// host -> SET Y global_scaler 80
|
||||||
|
// ...
|
||||||
|
//
|
||||||
|
// This module is the pure part: read a NEED line, and work out which SET lines
|
||||||
|
// answer it. Sending them, and deciding what to do when some cannot be answered,
|
||||||
|
// belongs to the caller.
|
||||||
|
|
||||||
|
// A NEED line: the firmware naming the parameters it will not run without.
|
||||||
|
struct NeedRequest {
|
||||||
|
char axis = '?'; // 'Y' or 'P'
|
||||||
|
std::vector<std::string> keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns nullopt for anything that is not a well-formed NEED line. A NEED with
|
||||||
|
// no keys is well-formed and means "this axis needs nothing" - which is how a
|
||||||
|
// firmware built for fewer axes than the rig has answers for the axis it lacks.
|
||||||
|
std::optional<NeedRequest> parseNeedLine(const std::string& line);
|
||||||
|
|
||||||
|
// Everything the host can answer for one axis. Each group carries its own
|
||||||
|
// "declared" flag because a value the host does not have must be reported as
|
||||||
|
// unanswered rather than sent as a zero - a zero here is a real setting, and a
|
||||||
|
// silently zeroed current or margin is a damaged rig.
|
||||||
|
struct AxisConfigSource {
|
||||||
|
// From [Motor.<axis>] milliamps, via TmcCurrent.h.
|
||||||
|
bool have_current = false;
|
||||||
|
int global_scaler = 0;
|
||||||
|
int run_irun = 0;
|
||||||
|
int homing_irun = 0;
|
||||||
|
|
||||||
|
// From [Motor] calibration and margins. Always known - they have defaults
|
||||||
|
// that predate hardware.ini and are already sent as CENTER / MARGIN.
|
||||||
|
long center_count = 0;
|
||||||
|
long soft_margin = 0;
|
||||||
|
|
||||||
|
// From [Motor.<axis>], the mechanism's own facts.
|
||||||
|
bool have_travel = false;
|
||||||
|
long steps_per_rev = 0; // expected full mechanical travel, in counts
|
||||||
|
bool have_deviation = false;
|
||||||
|
long enc_deviation = 0;
|
||||||
|
|
||||||
|
// Speeds, already converted from deg/s to VMAX by the caller (AxisSpeed.h).
|
||||||
|
// 0 means the conversion was refused - an uncalibrated scale - and the key
|
||||||
|
// goes unanswered rather than stopping the axis with a VMAX of zero.
|
||||||
|
long normal_vmax = 0;
|
||||||
|
long homing_vmax = 0;
|
||||||
|
long center_vmax = 0;
|
||||||
|
|
||||||
|
// Wiring facts: whether this axis has physical endstop switches on the TMC's
|
||||||
|
// REFL/REFR, and whether its encoder is aligned and usable.
|
||||||
|
bool have_wiring = false;
|
||||||
|
unsigned sw_mode = 0;
|
||||||
|
bool has_encoder = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FirmwareConfigPlan {
|
||||||
|
// Ready-to-send command lines, in the order the firmware asked for them.
|
||||||
|
std::vector<std::string> sets;
|
||||||
|
// Keys the host was asked for and has no value for. The firmware stays in
|
||||||
|
// NEEDCFG until they are answered, so this is a startup failure, not a
|
||||||
|
// warning to scroll past.
|
||||||
|
std::vector<std::string> unanswered;
|
||||||
|
// Keys that are not part of the vocabulary at all: a firmware newer than
|
||||||
|
// this host. Kept separate from `unanswered` because the fix is different -
|
||||||
|
// upgrade the host, rather than fill in hardware.ini.
|
||||||
|
std::vector<std::string> unknown;
|
||||||
|
|
||||||
|
bool complete() const { return unanswered.empty() && unknown.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map one NEED request onto SET lines. `center` / `margin` are accepted as
|
||||||
|
// aliases for `center_count` / `soft_margin`, because those are the names the
|
||||||
|
// existing CENTER and MARGIN commands use and hand-typed sessions rely on them.
|
||||||
|
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src);
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
|
@ -70,6 +71,14 @@ public:
|
||||||
virtual std::string lastDiag() = 0;
|
virtual std::string lastDiag() = 0;
|
||||||
virtual unsigned diagSeq() const = 0;
|
virtual unsigned diagSeq() const = 0;
|
||||||
|
|
||||||
|
// NEED lines the firmware has emitted since they were last taken, one per
|
||||||
|
// axis, in arrival order (hardware-config-plan.md section 6). Taking them
|
||||||
|
// clears the queue: they are a request to answer once, not a state to poll.
|
||||||
|
//
|
||||||
|
// Default: none. Only the real controller sees the wire; the mock has no
|
||||||
|
// firmware to ask, and a null implementation would have nothing to say.
|
||||||
|
virtual std::vector<std::string> takeNeedLines() { return {}; }
|
||||||
|
|
||||||
// Whether the underlying link is usable.
|
// Whether the underlying link is usable.
|
||||||
virtual bool connected() const = 0;
|
virtual bool connected() const = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ public:
|
||||||
std::string lastDump() override;
|
std::string lastDump() override;
|
||||||
std::string lastDiag() override;
|
std::string lastDiag() override;
|
||||||
unsigned diagSeq() const override;
|
unsigned diagSeq() const override;
|
||||||
|
std::vector<std::string> takeNeedLines() override;
|
||||||
bool connected() const override;
|
bool connected() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
#include "fgc/IMotorController.h"
|
#include "fgc/IMotorController.h"
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
|
|
||||||
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
|
@ -16,7 +19,38 @@ namespace fgc {
|
||||||
// move -> settle -> trigger loop runs exactly as it would against real hardware.
|
// move -> settle -> trigger loop runs exactly as it would against real hardware.
|
||||||
class MockMotorController : public IMotorController {
|
class MockMotorController : public IMotorController {
|
||||||
public:
|
public:
|
||||||
void start() override { LOG_INFO << "[mock] motor controller started"; }
|
// `needcfg`: emit NEED lines on start and refuse to move until they are
|
||||||
|
// answered, the way the real firmware will (hardware-config-plan.md
|
||||||
|
// section 6). Off by default so every existing test sees the old mock.
|
||||||
|
//
|
||||||
|
// This is what lets the HOST half of the handshake be built and verified
|
||||||
|
// before the firmware half exists - and it is not throwaway: it stays as the
|
||||||
|
// way the handshake is exercised on a dev box with no rig.
|
||||||
|
explicit MockMotorController(bool needcfg = false) : needcfg_(needcfg) {}
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
LOG_INFO << "[mock] motor controller started";
|
||||||
|
if (!needcfg_) return;
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
static const char* kKeys[] = {"steps_per_rev", "global_scaler", "sw_mode",
|
||||||
|
"enc_deviation", "normal_speed", "homing_speed",
|
||||||
|
"center_speed", "center", "margin"};
|
||||||
|
need_lines_.clear();
|
||||||
|
unanswered_.clear();
|
||||||
|
for (const char* axis : {"Y", "P"}) {
|
||||||
|
std::string line = std::string("NEED ") + axis;
|
||||||
|
for (const char* k : kKeys) {
|
||||||
|
line += std::string(" ") + k;
|
||||||
|
// Track the same set the line asks for, so the axis unlocks only
|
||||||
|
// when EVERY key has been answered - a partial answer must leave
|
||||||
|
// it in NEEDCFG, which is the behaviour worth simulating.
|
||||||
|
unanswered_.insert(std::string(axis) + "." + k);
|
||||||
|
}
|
||||||
|
need_lines_.push_back(line);
|
||||||
|
}
|
||||||
|
configured_ = false;
|
||||||
|
LOG_INFO << "[mock] NEEDCFG: asking the host for config, refusing to move until answered";
|
||||||
|
}
|
||||||
void stop() override { LOG_INFO << "[mock] motor controller stopped"; }
|
void stop() override { LOG_INFO << "[mock] motor controller stopped"; }
|
||||||
|
|
||||||
void sendCommand(const std::string& cmd) override {
|
void sendCommand(const std::string& cmd) override {
|
||||||
|
|
@ -27,6 +61,21 @@ public:
|
||||||
in >> verb;
|
in >> verb;
|
||||||
for (auto& c : verb) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
for (auto& c : verb) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
|
||||||
|
if (verb == "SET") {
|
||||||
|
// Record what the host answered, so a test can assert the handshake
|
||||||
|
// actually put the right values on the wire.
|
||||||
|
std::string axis, key, value;
|
||||||
|
in >> axis >> key >> value;
|
||||||
|
settings_[axis + "." + key] = value;
|
||||||
|
unanswered_.erase(axis + "." + key);
|
||||||
|
if (unanswered_.empty()) configured_ = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (needcfg_ && !configured_ &&
|
||||||
|
(verb == "HOME" || verb == "MOVE" || verb == "ENABLE")) {
|
||||||
|
LOG_WARN << "[mock] ERR need config (" << verb << " rejected)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (verb == "HOME") {
|
if (verb == "HOME") {
|
||||||
homed_ = true;
|
homed_ = true;
|
||||||
deviation_ = false; // homing clears the latch, as on the real driver
|
deviation_ = false; // homing clears the latch, as on the real driver
|
||||||
|
|
@ -131,9 +180,29 @@ public:
|
||||||
}
|
}
|
||||||
unsigned diagSeq() const override { return diag_seq_; }
|
unsigned diagSeq() const override { return diag_seq_; }
|
||||||
|
|
||||||
|
std::vector<std::string> takeNeedLines() override {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.swap(need_lines_);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/inspection hooks: what the host SET, and whether the axis is unlocked.
|
||||||
|
std::map<std::string, std::string> settings() const {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
return settings_;
|
||||||
|
}
|
||||||
|
bool configured() const { return configured_; }
|
||||||
|
|
||||||
bool connected() const override { return true; }
|
bool connected() const override { return true; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
bool needcfg_ = false;
|
||||||
|
bool configured_ = false;
|
||||||
|
std::vector<std::string> need_lines_;
|
||||||
|
std::map<std::string, std::string> settings_;
|
||||||
|
std::set<std::string> unanswered_;
|
||||||
|
|
||||||
static constexpr long kStep = 80000; // counts advanced per telemetry read
|
static constexpr long kStep = 80000; // counts advanced per telemetry read
|
||||||
|
|
||||||
void step(AxisTelemetry& a, long target) {
|
void step(AxisTelemetry& a, long target) {
|
||||||
|
|
@ -156,7 +225,7 @@ private:
|
||||||
} catch (const std::exception&) {}
|
} catch (const std::exception&) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
AxisTelemetry yaw_;
|
AxisTelemetry yaw_;
|
||||||
AxisTelemetry pitch_;
|
AxisTelemetry pitch_;
|
||||||
long yaw_target_ = 0;
|
long yaw_target_ = 0;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include "fgc/Paths.h"
|
#include "fgc/Paths.h"
|
||||||
#include "fgc/CameraProfile.h"
|
#include "fgc/CameraProfile.h"
|
||||||
#include "fgc/AxisSpeed.h"
|
#include "fgc/AxisSpeed.h"
|
||||||
|
#include "fgc/FirmwareConfig.h"
|
||||||
#include "fgc/HardwarePresence.h"
|
#include "fgc/HardwarePresence.h"
|
||||||
#include "fgc/TmcCurrent.h"
|
#include "fgc/TmcCurrent.h"
|
||||||
#include "fgc/ICameraSource.h"
|
#include "fgc/ICameraSource.h"
|
||||||
|
|
@ -304,7 +305,7 @@ struct Application::Impl {
|
||||||
|
|
||||||
std::unique_ptr<IMotorController> makeMotor() {
|
std::unique_ptr<IMotorController> makeMotor() {
|
||||||
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
|
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
|
||||||
if (mock) return std::make_unique<MockMotorController>();
|
if (mock) return std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
|
||||||
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
|
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1177,6 +1178,102 @@ struct Application::Impl {
|
||||||
one("pitch", 'P', cfg.geometry.pitch, cfg.drivetrain.pitch);
|
one("pitch", 'P', cfg.geometry.pitch, cfg.drivetrain.pitch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build what the host can answer for one axis, for the config handshake.
|
||||||
|
//
|
||||||
|
// Speeds are converted here rather than declared as VMAX, for the reason in
|
||||||
|
// AxisSpeed.h. Homing and centring happen BEFORE the first home, so they
|
||||||
|
// cannot use the counts_per_deg that homing derives - they use the scale the
|
||||||
|
// declaration implies (steps_per_rev / travel_deg), which on the IOTA agrees
|
||||||
|
// with the measured one to 0.25%.
|
||||||
|
AxisConfigSource axisConfigSource(const char* axis, const AxisMap& map,
|
||||||
|
const AxisDrivetrainConfig& d, const TmcCurrentPlan& cur,
|
||||||
|
long soft_margin) {
|
||||||
|
AxisConfigSource s;
|
||||||
|
s.have_current = cur.ok;
|
||||||
|
s.global_scaler = cur.global_scaler;
|
||||||
|
s.run_irun = cur.run_irun;
|
||||||
|
s.homing_irun = cur.homing_irun;
|
||||||
|
|
||||||
|
s.center_count = map.center_count;
|
||||||
|
s.soft_margin = soft_margin;
|
||||||
|
|
||||||
|
s.have_travel = d.steps_per_rev > 0;
|
||||||
|
s.steps_per_rev = d.steps_per_rev;
|
||||||
|
s.have_deviation = d.enc_deviation > 0;
|
||||||
|
s.enc_deviation = d.enc_deviation;
|
||||||
|
|
||||||
|
s.have_wiring = d.wiring_declared;
|
||||||
|
s.sw_mode = d.sw_mode;
|
||||||
|
s.has_encoder = d.has_encoder;
|
||||||
|
|
||||||
|
// The scale to convert through. The firmware asks for all three speeds at
|
||||||
|
// BOOT, before anything has been homed, so the live calibration is often
|
||||||
|
// still AxisMap's uncalibrated default - fall back to the one the
|
||||||
|
// declaration implies. Homing supersedes it: sendProductionSpeeds() re-sends
|
||||||
|
// the production speed against the measured scale once it exists.
|
||||||
|
const double live = std::fabs(map.counts_per_deg);
|
||||||
|
const double cpd = live >= kMinPlausibleCountsPerDeg ? map.counts_per_deg
|
||||||
|
: d.preHomeCountsPerDeg();
|
||||||
|
auto vmax = [&](double deg_s) -> long {
|
||||||
|
if (deg_s <= 0.0) return 0; // undeclared: firmware default stands
|
||||||
|
const auto p = planAxisSpeed(deg_s, cpd);
|
||||||
|
if (!p.ok) {
|
||||||
|
LOG_WARN << axis << " " << deg_s << " deg/s could not be converted: " << p.error;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return p.vmax;
|
||||||
|
};
|
||||||
|
s.normal_vmax = vmax(d.scan_speed_deg_s);
|
||||||
|
s.homing_vmax = vmax(d.homing_speed_deg_s);
|
||||||
|
s.center_vmax = vmax(d.center_speed_deg_s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer any NEED lines the firmware has emitted.
|
||||||
|
//
|
||||||
|
// The firmware stays in NEEDCFG and moves nothing until its required set is
|
||||||
|
// complete, so an unanswered key is a startup failure rather than a warning:
|
||||||
|
// it is named here, loudly, because the fix is in hardware.ini and nothing
|
||||||
|
// else will make the gimbal move.
|
||||||
|
void answerFirmwareNeeds() {
|
||||||
|
for (const auto& line : motor->takeNeedLines()) {
|
||||||
|
const auto need = parseNeedLine(line);
|
||||||
|
if (!need) {
|
||||||
|
LOG_WARN << "unparseable NEED line from the firmware: " << line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool is_yaw = need->axis == 'Y';
|
||||||
|
const auto src = is_yaw
|
||||||
|
? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw,
|
||||||
|
yaw_current_, cfg.yaw_soft_margin_counts)
|
||||||
|
: axisConfigSource("pitch", cfg.geometry.pitch,
|
||||||
|
cfg.drivetrain.pitch, pitch_current_,
|
||||||
|
cfg.pitch_soft_margin_counts);
|
||||||
|
|
||||||
|
const auto plan = planFirmwareConfig(*need, src);
|
||||||
|
for (const auto& cmd : plan.sets) motor->sendCommand(cmd);
|
||||||
|
|
||||||
|
if (!plan.unanswered.empty()) {
|
||||||
|
std::string keys;
|
||||||
|
for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k;
|
||||||
|
LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
|
||||||
|
<< " and hardware.ini does not declare "
|
||||||
|
<< (plan.unanswered.size() == 1 ? "it" : "them")
|
||||||
|
<< "; the axis stays in NEEDCFG and will not move";
|
||||||
|
}
|
||||||
|
if (!plan.unknown.empty()) {
|
||||||
|
std::string keys;
|
||||||
|
for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k;
|
||||||
|
LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
|
||||||
|
<< ", which this host does not know -- the firmware is newer than the "
|
||||||
|
"host; upgrade the host rather than editing hardware.ini";
|
||||||
|
}
|
||||||
|
if (plan.complete())
|
||||||
|
LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for axis "
|
||||||
|
<< need->axis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert this tower's declared milliamps into TMC register fields, once.
|
// Convert this tower's declared milliamps into TMC register fields, once.
|
||||||
// The host owns this because hardware.ini is where the sense resistor lives;
|
// The host owns this because hardware.ini is where the sense resistor lives;
|
||||||
// the firmware is told registers and keeps working compile-time defaults.
|
// the firmware is told registers and keeps working compile-time defaults.
|
||||||
|
|
@ -2490,6 +2587,7 @@ struct Application::Impl {
|
||||||
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
|
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
|
||||||
while (running && !g_shutdown_requested.load()) {
|
while (running && !g_shutdown_requested.load()) {
|
||||||
drainCommands();
|
drainCommands();
|
||||||
|
answerFirmwareNeeds(); // the firmware asks; hardware.ini answers
|
||||||
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
|
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
|
||||||
scheduler->tick();
|
scheduler->tick();
|
||||||
checkTrackingError(); // warn on live encoder-error excursions
|
checkTrackingError(); // warn on live encoder-error excursions
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu);
|
cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu);
|
||||||
cfg.features.enable_env = getBool(kv, "Features.enable_env", cfg.features.enable_env);
|
cfg.features.enable_env = getBool(kv, "Features.enable_env", cfg.features.enable_env);
|
||||||
cfg.features.mock_env = getBool(kv, "Features.mock_env", cfg.features.mock_env);
|
cfg.features.mock_env = getBool(kv, "Features.mock_env", cfg.features.mock_env);
|
||||||
|
cfg.features.mock_needcfg = getBool(kv, "Features.mock_needcfg", cfg.features.mock_needcfg);
|
||||||
cfg.features.strict_presence =
|
cfg.features.strict_presence =
|
||||||
getBool(kv, "Features.strict_presence", cfg.features.strict_presence);
|
getBool(kv, "Features.strict_presence", cfg.features.strict_presence);
|
||||||
|
|
||||||
|
|
@ -465,6 +466,13 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
d.homing_current_ma = getDouble(kv, pre + "homing_current_ma", d.run_current_ma);
|
d.homing_current_ma = getDouble(kv, pre + "homing_current_ma", d.run_current_ma);
|
||||||
d.travel_deg = getDouble(kv, pre + "travel_deg", d.travel_deg);
|
d.travel_deg = getDouble(kv, pre + "travel_deg", d.travel_deg);
|
||||||
d.scan_speed_deg_s = getDouble(kv, pre + "scan_speed_deg_s", d.scan_speed_deg_s);
|
d.scan_speed_deg_s = getDouble(kv, pre + "scan_speed_deg_s", d.scan_speed_deg_s);
|
||||||
|
d.homing_speed_deg_s = getDouble(kv, pre + "homing_speed_deg_s", d.homing_speed_deg_s);
|
||||||
|
d.center_speed_deg_s = getDouble(kv, pre + "center_speed_deg_s", d.center_speed_deg_s);
|
||||||
|
d.steps_per_rev = getInt(kv, pre + "steps_per_rev", static_cast<int>(d.steps_per_rev));
|
||||||
|
d.enc_deviation = getInt(kv, pre + "enc_deviation", static_cast<int>(d.enc_deviation));
|
||||||
|
d.wiring_declared = kv.count(pre + "sw_mode") || kv.count(pre + "has_encoder");
|
||||||
|
d.sw_mode = static_cast<unsigned>(getInt(kv, pre + "sw_mode", static_cast<int>(d.sw_mode)));
|
||||||
|
d.has_encoder = getBool(kv, pre + "has_encoder", d.has_encoder);
|
||||||
};
|
};
|
||||||
axisDrivetrain("yaw", cfg.drivetrain.yaw);
|
axisDrivetrain("yaw", cfg.drivetrain.yaw);
|
||||||
axisDrivetrain("pitch", cfg.drivetrain.pitch);
|
axisDrivetrain("pitch", cfg.drivetrain.pitch);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
#include "fgc/FirmwareConfig.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// One answerable key: where its value comes from, and whether the host has it.
|
||||||
|
struct Answer {
|
||||||
|
bool have = false;
|
||||||
|
long long value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
Answer answerFor(const std::string& key, const AxisConfigSource& s) {
|
||||||
|
// Motor current. All three come from the same conversion, so they are
|
||||||
|
// available together or not at all.
|
||||||
|
if (key == "global_scaler") return {s.have_current, s.global_scaler};
|
||||||
|
if (key == "run_irun") return {s.have_current, s.run_irun};
|
||||||
|
if (key == "homing_irun") return {s.have_current, s.homing_irun};
|
||||||
|
|
||||||
|
// Position anchors. `center` / `margin` are the names the existing CENTER
|
||||||
|
// and MARGIN commands use.
|
||||||
|
if (key == "center_count" || key == "center") return {true, s.center_count};
|
||||||
|
if (key == "soft_margin" || key == "margin") return {true, s.soft_margin};
|
||||||
|
|
||||||
|
if (key == "steps_per_rev") return {s.have_travel, s.steps_per_rev};
|
||||||
|
if (key == "enc_deviation") return {s.have_deviation, s.enc_deviation};
|
||||||
|
|
||||||
|
// A VMAX of 0 is not a slow axis, it is a stopped one, so an unconverted
|
||||||
|
// speed is reported as missing rather than sent.
|
||||||
|
if (key == "normal_speed") return {s.normal_vmax > 0, s.normal_vmax};
|
||||||
|
if (key == "homing_speed") return {s.homing_vmax > 0, s.homing_vmax};
|
||||||
|
if (key == "center_speed") return {s.center_vmax > 0, s.center_vmax};
|
||||||
|
|
||||||
|
if (key == "sw_mode") return {s.have_wiring, static_cast<long long>(s.sw_mode)};
|
||||||
|
if (key == "has_encoder") return {s.have_wiring, s.has_encoder ? 1 : 0};
|
||||||
|
|
||||||
|
return {}; // not in the vocabulary
|
||||||
|
}
|
||||||
|
|
||||||
|
bool known(const std::string& key) {
|
||||||
|
static const char* kKeys[] = {
|
||||||
|
"global_scaler", "run_irun", "homing_irun", "center_count", "center",
|
||||||
|
"soft_margin", "margin", "steps_per_rev", "enc_deviation",
|
||||||
|
"normal_speed", "homing_speed", "center_speed", "sw_mode", "has_encoder",
|
||||||
|
};
|
||||||
|
for (const char* k : kKeys)
|
||||||
|
if (key == k) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<NeedRequest> parseNeedLine(const std::string& line) {
|
||||||
|
std::istringstream is(line);
|
||||||
|
std::string tok;
|
||||||
|
if (!(is >> tok) || tok != "NEED") return std::nullopt;
|
||||||
|
|
||||||
|
std::string axis;
|
||||||
|
if (!(is >> axis)) return std::nullopt;
|
||||||
|
if (axis.size() != 1) return std::nullopt;
|
||||||
|
const char a = static_cast<char>(std::toupper(static_cast<unsigned char>(axis[0])));
|
||||||
|
if (a != 'Y' && a != 'P') return std::nullopt;
|
||||||
|
|
||||||
|
NeedRequest r;
|
||||||
|
r.axis = a;
|
||||||
|
while (is >> tok) r.keys.push_back(tok);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src) {
|
||||||
|
FirmwareConfigPlan plan;
|
||||||
|
const std::string axis(1, need.axis);
|
||||||
|
|
||||||
|
for (const auto& key : need.keys) {
|
||||||
|
if (!known(key)) {
|
||||||
|
// The firmware wants something this host has never heard of, which
|
||||||
|
// means it is newer. Naming it is the whole diagnostic.
|
||||||
|
plan.unknown.push_back(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const Answer a = answerFor(key, src);
|
||||||
|
if (!a.have) {
|
||||||
|
plan.unanswered.push_back(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plan.sets.push_back("SET " + axis + " " + key + " " + std::to_string(a.value));
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -78,6 +78,9 @@ struct SerialMotorController::Impl {
|
||||||
std::string latest_diag;
|
std::string latest_diag;
|
||||||
std::atomic<unsigned> diag_seq{0};
|
std::atomic<unsigned> diag_seq{0};
|
||||||
|
|
||||||
|
// NEED capture: single lines, queued for the control thread to answer once.
|
||||||
|
std::vector<std::string> need_lines;
|
||||||
|
|
||||||
// Write one command line (newline-terminated) to the controller. Used by
|
// Write one command line (newline-terminated) to the controller. Used by
|
||||||
// sendCommand and by the internal DUMP re-request.
|
// sendCommand and by the internal DUMP re-request.
|
||||||
//
|
//
|
||||||
|
|
@ -137,13 +140,24 @@ struct SerialMotorController::Impl {
|
||||||
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
||||||
LOG_WARN << "Motor controller: " << line;
|
LOG_WARN << "Motor controller: " << line;
|
||||||
} else if (!line.empty()) {
|
} else if (!line.empty()) {
|
||||||
// OK acks and other async output (DG/DUMP/BOOT/...).
|
// OK acks and other async output (DG/DUMP/BOOT/NEED/...).
|
||||||
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
||||||
captureDump(line);
|
captureDump(line);
|
||||||
captureDiag(line);
|
captureDiag(line);
|
||||||
|
captureNeed(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue a NEED line for the control thread to answer. Unlike DUMP and DIAG
|
||||||
|
// this is not a block to assemble - each line stands alone and is actionable
|
||||||
|
// immediately, so it is queued rather than accumulated, and taking it clears
|
||||||
|
// it. A firmware that re-asks (after a RESET, say) simply queues again.
|
||||||
|
void captureNeed(const std::string& line) {
|
||||||
|
if (line.rfind("NEED ", 0) != 0) return;
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
need_lines.push_back(line);
|
||||||
|
}
|
||||||
|
|
||||||
// Assemble the DIAG stream. DG lines arrive interleaved with ST over many
|
// Assemble the DIAG stream. DG lines arrive interleaved with ST over many
|
||||||
// seconds, so we accumulate only "DG " lines and log each live; on "DG DONE"
|
// seconds, so we accumulate only "DG " lines and log each live; on "DG DONE"
|
||||||
// we publish the block and bump diag_seq so the app can pick up the result.
|
// we publish the block and bump diag_seq so the app can pick up the result.
|
||||||
|
|
@ -281,6 +295,13 @@ std::string SerialMotorController::lastDiag() {
|
||||||
|
|
||||||
unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); }
|
unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); }
|
||||||
|
|
||||||
|
std::vector<std::string> SerialMotorController::takeNeedLines() {
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.swap(impl_->need_lines);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
bool SerialMotorController::connected() const { return impl_->connected; }
|
bool SerialMotorController::connected() const { return impl_->connected; }
|
||||||
|
|
||||||
} // namespace fgc
|
} // namespace fgc
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ add_executable(fgc_tests
|
||||||
test_hardwarepresence.cpp
|
test_hardwarepresence.cpp
|
||||||
test_hardwarechecks.cpp
|
test_hardwarechecks.cpp
|
||||||
test_axisspeed.cpp
|
test_axisspeed.cpp
|
||||||
|
test_firmwareconfig.cpp
|
||||||
test_cameraprofile.cpp
|
test_cameraprofile.cpp
|
||||||
test_camerasettingsxml.cpp
|
test_camerasettingsxml.cpp
|
||||||
test_hash.cpp
|
test_hash.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
#include "fgc/FirmwareConfig.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
using namespace fgc;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Tower 121's yaw, fully declared: the case the handshake exists to serve.
|
||||||
|
AxisConfigSource iotaYaw() {
|
||||||
|
AxisConfigSource s;
|
||||||
|
s.have_current = true;
|
||||||
|
s.global_scaler = 80;
|
||||||
|
s.run_irun = 31;
|
||||||
|
s.homing_irun = 7;
|
||||||
|
s.center_count = 500000;
|
||||||
|
s.soft_margin = 5000;
|
||||||
|
s.have_travel = true;
|
||||||
|
s.steps_per_rev = 1210778;
|
||||||
|
s.have_deviation = true;
|
||||||
|
s.enc_deviation = 1500;
|
||||||
|
s.normal_vmax = 39738; // 8 deg/s at 3552.87 counts/deg
|
||||||
|
s.homing_vmax = 30000;
|
||||||
|
s.center_vmax = 25000;
|
||||||
|
s.have_wiring = true;
|
||||||
|
s.sw_mode = 0; // yaw: no physical stops, StallGuard homing
|
||||||
|
s.has_encoder = true;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sent(const FirmwareConfigPlan& p, const std::string& text) {
|
||||||
|
return std::find(p.sets.begin(), p.sets.end(), text) != p.sets.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("a NEED line parses into an axis and its keys") {
|
||||||
|
auto r = parseNeedLine("NEED Y steps_per_rev global_scaler center margin");
|
||||||
|
REQUIRE(r);
|
||||||
|
CHECK(r->axis == 'Y');
|
||||||
|
REQUIRE(r->keys.size() == 4);
|
||||||
|
CHECK(r->keys[0] == "steps_per_rev");
|
||||||
|
CHECK(r->keys[3] == "margin");
|
||||||
|
|
||||||
|
auto p = parseNeedLine("NEED P sw_mode");
|
||||||
|
REQUIRE(p);
|
||||||
|
CHECK(p->axis == 'P');
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("an axis that needs nothing is well-formed, not a parse failure") {
|
||||||
|
// How a firmware built for fewer axes than the rig has answers for the axis
|
||||||
|
// it does not drive.
|
||||||
|
auto r = parseNeedLine("NEED P");
|
||||||
|
REQUIRE(r);
|
||||||
|
CHECK(r->axis == 'P');
|
||||||
|
CHECK(r->keys.empty());
|
||||||
|
CHECK(planFirmwareConfig(*r, iotaYaw()).complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("anything that is not a NEED line is ignored") {
|
||||||
|
CHECK_FALSE(parseNeedLine("ST Y:R,0,0,00000000,0,0,0,S"));
|
||||||
|
CHECK_FALSE(parseNeedLine("NEEDCFG"));
|
||||||
|
CHECK_FALSE(parseNeedLine("NEED"));
|
||||||
|
CHECK_FALSE(parseNeedLine("NEED X steps_per_rev")); // not an axis
|
||||||
|
CHECK_FALSE(parseNeedLine("NEED YY steps_per_rev"));
|
||||||
|
CHECK_FALSE(parseNeedLine(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a fully declared axis is answered completely, in the order asked") {
|
||||||
|
auto r = parseNeedLine(
|
||||||
|
"NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed "
|
||||||
|
"center_speed center margin");
|
||||||
|
REQUIRE(r);
|
||||||
|
auto p = planFirmwareConfig(*r, iotaYaw());
|
||||||
|
|
||||||
|
CHECK(p.complete());
|
||||||
|
CHECK(p.unanswered.empty());
|
||||||
|
CHECK(p.unknown.empty());
|
||||||
|
REQUIRE(p.sets.size() == 9);
|
||||||
|
CHECK(p.sets.front() == "SET Y steps_per_rev 1210778");
|
||||||
|
CHECK(sent(p, "SET Y global_scaler 80"));
|
||||||
|
CHECK(sent(p, "SET Y enc_deviation 1500"));
|
||||||
|
CHECK(sent(p, "SET Y normal_speed 39738"));
|
||||||
|
CHECK(p.sets.back() == "SET Y margin 5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("center/margin are accepted under both their names") {
|
||||||
|
auto legacy = parseNeedLine("NEED Y center margin");
|
||||||
|
auto full = parseNeedLine("NEED Y center_count soft_margin");
|
||||||
|
REQUIRE(legacy);
|
||||||
|
REQUIRE(full);
|
||||||
|
CHECK(planFirmwareConfig(*legacy, iotaYaw()).sets[0] == "SET Y center 500000");
|
||||||
|
CHECK(planFirmwareConfig(*full, iotaYaw()).sets[0] == "SET Y center_count 500000");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("an undeclared current is unanswered, never sent as zero") {
|
||||||
|
// A silently zeroed current is a motor that cannot hold, so the key must go
|
||||||
|
// unanswered and leave the firmware in NEEDCFG.
|
||||||
|
auto s = iotaYaw();
|
||||||
|
s.have_current = false;
|
||||||
|
auto r = parseNeedLine("NEED Y global_scaler run_irun homing_irun");
|
||||||
|
REQUIRE(r);
|
||||||
|
auto p = planFirmwareConfig(*r, s);
|
||||||
|
|
||||||
|
CHECK_FALSE(p.complete());
|
||||||
|
CHECK(p.sets.empty());
|
||||||
|
CHECK(p.unanswered.size() == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a speed that could not be converted is unanswered, not VMAX 0") {
|
||||||
|
// VMAX 0 is a stopped axis, not a slow one. AxisSpeed refuses an
|
||||||
|
// uncalibrated scale, and that refusal has to survive into the handshake.
|
||||||
|
auto s = iotaYaw();
|
||||||
|
s.normal_vmax = 0;
|
||||||
|
auto r = parseNeedLine("NEED Y normal_speed homing_speed");
|
||||||
|
REQUIRE(r);
|
||||||
|
auto p = planFirmwareConfig(*r, s);
|
||||||
|
|
||||||
|
CHECK_FALSE(p.complete());
|
||||||
|
REQUIRE(p.unanswered.size() == 1);
|
||||||
|
CHECK(p.unanswered[0] == "normal_speed");
|
||||||
|
CHECK(sent(p, "SET Y homing_speed 30000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a key this host has never heard of is a version skew, not a gap") {
|
||||||
|
// The two need different fixes - upgrade the host vs. fill in hardware.ini -
|
||||||
|
// so they are reported separately.
|
||||||
|
auto r = parseNeedLine("NEED Y steps_per_rev flux_capacitor");
|
||||||
|
REQUIRE(r);
|
||||||
|
auto p = planFirmwareConfig(*r, iotaYaw());
|
||||||
|
|
||||||
|
CHECK_FALSE(p.complete());
|
||||||
|
CHECK(p.unanswered.empty());
|
||||||
|
REQUIRE(p.unknown.size() == 1);
|
||||||
|
CHECK(p.unknown[0] == "flux_capacitor");
|
||||||
|
// The keys it DID understand are still answered.
|
||||||
|
CHECK(sent(p, "SET Y steps_per_rev 1210778"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("has_encoder is sent as 1/0, and follows the wiring declaration") {
|
||||||
|
auto s = iotaYaw();
|
||||||
|
s.has_encoder = false;
|
||||||
|
auto r = parseNeedLine("NEED Y has_encoder");
|
||||||
|
REQUIRE(r);
|
||||||
|
CHECK(planFirmwareConfig(*r, s).sets[0] == "SET Y has_encoder 0");
|
||||||
|
|
||||||
|
s.have_wiring = false;
|
||||||
|
CHECK_FALSE(planFirmwareConfig(*r, s).complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the pitch axis is addressed as P") {
|
||||||
|
auto r = parseNeedLine("NEED P center margin");
|
||||||
|
REQUIRE(r);
|
||||||
|
auto p = planFirmwareConfig(*r, iotaYaw());
|
||||||
|
CHECK(p.sets[0] == "SET P center 500000");
|
||||||
|
CHECK(p.sets[1] == "SET P margin 5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- End to end through the mock's NEEDCFG mode -----------------------------
|
||||||
|
|
||||||
|
#include "fgc/mock/MockMotorController.h"
|
||||||
|
|
||||||
|
TEST_CASE("the mock in NEEDCFG asks, and refuses to move until answered") {
|
||||||
|
MockMotorController m(/*needcfg=*/true);
|
||||||
|
m.start();
|
||||||
|
|
||||||
|
auto lines = m.takeNeedLines();
|
||||||
|
REQUIRE(lines.size() == 2);
|
||||||
|
CHECK(lines[0].rfind("NEED Y ", 0) == 0);
|
||||||
|
CHECK(lines[1].rfind("NEED P ", 0) == 0);
|
||||||
|
// Taking them clears the queue: they are a request, not a state to poll.
|
||||||
|
CHECK(m.takeNeedLines().empty());
|
||||||
|
|
||||||
|
// Before the answer, motion commands are rejected.
|
||||||
|
CHECK_FALSE(m.configured());
|
||||||
|
m.sendCommand("HOME");
|
||||||
|
CHECK_FALSE(m.telemetry().yaw.ready());
|
||||||
|
|
||||||
|
// Answer the way the host would.
|
||||||
|
AxisConfigSource src;
|
||||||
|
src.have_current = true;
|
||||||
|
src.global_scaler = 80;
|
||||||
|
src.center_count = 500000;
|
||||||
|
src.soft_margin = 5000;
|
||||||
|
src.have_travel = true;
|
||||||
|
src.steps_per_rev = 1210778;
|
||||||
|
src.have_deviation = true;
|
||||||
|
src.enc_deviation = 1500;
|
||||||
|
src.normal_vmax = 39738;
|
||||||
|
src.homing_vmax = 30000;
|
||||||
|
src.center_vmax = 25000;
|
||||||
|
src.have_wiring = true;
|
||||||
|
|
||||||
|
// Answer BOTH axes, as the host does - the axis unlocks only when every key
|
||||||
|
// it asked for has been answered.
|
||||||
|
for (const auto& l : lines) {
|
||||||
|
auto n = parseNeedLine(l);
|
||||||
|
REQUIRE(n);
|
||||||
|
auto plan = planFirmwareConfig(*n, src);
|
||||||
|
REQUIRE(plan.complete());
|
||||||
|
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The values landed, under the names the firmware asked for.
|
||||||
|
const auto got = m.settings();
|
||||||
|
CHECK(got.at("Y.steps_per_rev") == "1210778");
|
||||||
|
CHECK(got.at("Y.global_scaler") == "80");
|
||||||
|
CHECK(got.at("Y.normal_speed") == "39738");
|
||||||
|
CHECK(got.at("Y.center") == "500000");
|
||||||
|
CHECK(got.at("Y.margin") == "5000");
|
||||||
|
|
||||||
|
// And the axis now moves.
|
||||||
|
CHECK(m.configured());
|
||||||
|
m.sendCommand("HOME");
|
||||||
|
CHECK(m.telemetry().yaw.ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the default mock is untouched by the handshake") {
|
||||||
|
// Every existing test constructs it this way; it must behave as before.
|
||||||
|
MockMotorController m;
|
||||||
|
m.start();
|
||||||
|
CHECK(m.takeNeedLines().empty());
|
||||||
|
m.sendCommand("HOME");
|
||||||
|
CHECK(m.telemetry().yaw.ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a partial answer leaves the axis in NEEDCFG") {
|
||||||
|
// The case that matters: hardware.ini declares most of what the firmware
|
||||||
|
// wants, so most SETs land - and the axis still must not move.
|
||||||
|
MockMotorController m(/*needcfg=*/true);
|
||||||
|
m.start();
|
||||||
|
auto lines = m.takeNeedLines();
|
||||||
|
REQUIRE(lines.size() == 2);
|
||||||
|
|
||||||
|
auto need = parseNeedLine(lines[0]);
|
||||||
|
REQUIRE(need);
|
||||||
|
AxisConfigSource partial; // only the always-known anchors
|
||||||
|
partial.center_count = 500000;
|
||||||
|
partial.soft_margin = 5000;
|
||||||
|
|
||||||
|
auto plan = planFirmwareConfig(*need, partial);
|
||||||
|
CHECK_FALSE(plan.complete());
|
||||||
|
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
|
||||||
|
|
||||||
|
CHECK_FALSE(m.configured());
|
||||||
|
m.sendCommand("HOME");
|
||||||
|
CHECK_FALSE(m.telemetry().yaw.ready());
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue