Check the firmware was built with the pins hardware.ini lists

This commit is contained in:
pgdalmeida 2026-09-23 18:26:14 +02:00
parent 7a7e7ab0a5
commit 4049a85b9b
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
17 changed files with 330 additions and 8 deletions

View File

@ -69,6 +69,26 @@ cameras = rgb
device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00 device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
baud = 115200 baud = 115200
[Pins]
; How the MCU is wired to each axis's TMC5160 driver board. The host never sends
; these: the FIRMWARE is built from them. firmware/tools/gen_wiring.py reads this
; section from the tower's own copy of this file when the firmware is built on
; the tower, and refuses pins the board cannot use. Change a wire, change this,
; rebuild and re-flash. `test hardware motor` compares it with what the running
; firmware reports, so a flash built from an older copy shows up there.
;
; The reference loom for each board, with the MCU-pin -> driver-pin table, is in
; hardware/<board>/pins.ini.
;
; Leonardo (Delta): SPI is fixed on the ICSP header, so only CS and EN are named.
; RP2040 (IOTA): also spi_sck / spi_mosi / spi_miso, from SPI0's pin groups -
; SCK {2,6,18,22} MOSI {3,7,19,23} MISO {0,4,16,20}
; Only axes listed in [Hardware] axes get pins.
yaw_cs = 8
yaw_en = 7
pitch_cs = 1
pitch_en = 0
[IMU] [IMU]
; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi ; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi
; is on the RS-232 header, which is a hardware UART: a stable /dev/ttyS* node, ; is on the RS-232 header, which is a hardware UART: a stable /dev/ttyS* node,

View File

@ -227,7 +227,7 @@ this whole design is removing. Make it an explicit act.
Only **pin assignments** and the **axis count**: pins because SPI must be up before anything can be Only **pin assignments** and the **axis count**: pins because SPI must be up before anything can be
asked or answered, the axis count because it sizes the axis array. Both are still defined once in asked or answered, the axis count because it sizes the axis array. Both are still defined once in
`hardware.ini` and *generated* into `firmware/src/config.h` at build — one definition, realised at build `hardware.ini` (`[Pins]`, `[Hardware] axes`) and *generated* into `board_wiring.h` at build — one definition, realised at build
time instead of over the wire. Everything else becomes runtime, which shrinks the build-time set far time instead of over the wire. Everything else becomes runtime, which shrinks the build-time set far
below what I proposed last turn. below what I proposed last turn.
@ -466,6 +466,7 @@ after the table.
| 6 | Config handshake — **firmware half** | **done, and validated on tower 121.** `NEEDCFG` state (`ST` code `C`), `NEED` emitted with the banner, `SET <axis> <key> <value>`, `USE DEFAULTS`, and the init block split so the residual-motion stop still runs at boot. 33 native tests pass under `pio test -e native`. On the rig: the handshake was watched live, `HOME` refused with `ERR need config`, `RESET` did not escape the state, and `USE DEFAULTS` released it | | 6 | Config handshake — **firmware half** | **done, and validated on tower 121.** `NEEDCFG` state (`ST` code `C`), `NEED` emitted with the banner, `SET <axis> <key> <value>`, `USE DEFAULTS`, and the init block split so the residual-motion stop still runs at boot. 33 native tests pass under `pio test -e native`. On the rig: the handshake was watched live, `HOME` refused with `ERR need config`, `RESET` did not escape the state, and `USE DEFAULTS` released it |
| 6 | Adoption check generalised | **done**, 9 tests. `DUMP` echoes every host-owned value plus a `cfg_rx` bitmask; `test hardware motor` checks all of them, and reports an absent field as unconfirmable rather than as a mismatch against -1. Round trip NEED -> SET -> DUMP -> PASS verified against `mock_needcfg` | | 6 | Adoption check generalised | **done**, 9 tests. `DUMP` echoes every host-owned value plus a `cfg_rx` bitmask; `test hardware motor` checks all of them, and reports an absent field as unconfirmable rather than as a mismatch against -1. Round trip NEED -> SET -> DUMP -> PASS verified against `mock_needcfg` |
| 14 | Drivetrain out of `MOTOR_CONFIG` | **done.** The struct keeps pins and `homing_kind` only; `platform_iota.h` keeps its measurement record but no longer defines this tower's values. `USE DEFAULTS` now loads a shared **limp-home** set (`LIMP_*` in `config.h`) that is deliberately nobody's real values — slow speeds, generous margins, the gentle search current — so a tower still running them fails `test hardware motor` instead of looking configured. `SW_MODE` is derived from `homing_kind` rather than shared, because disabling the hardware stops on an axis that homes on real switches would remove its last protection. Flash cost, not saved: see the porting plan. Found a real bug on the way — `DIAG` read `normal_speed` / `center_speed` from the compile-time table, so it ran at compiled speeds rather than the tower's declared ones | | 14 | Drivetrain out of `MOTOR_CONFIG` | **done.** The struct keeps pins and `homing_kind` only; `platform_iota.h` keeps its measurement record but no longer defines this tower's values. `USE DEFAULTS` now loads a shared **limp-home** set (`LIMP_*` in `config.h`) that is deliberately nobody's real values — slow speeds, generous margins, the gentle search current — so a tower still running them fails `test hardware motor` instead of looking configured. `SW_MODE` is derived from `homing_kind` rather than shared, because disabling the hardware stops on an axis that homes on real switches would remove its last protection. Flash cost, not saved: see the porting plan. Found a real bug on the way — `DIAG` read `normal_speed` / `center_speed` from the compile-time table, so it ran at compiled speeds rather than the tower's declared ones |
| 14 | Pins and `NUM_AXES` generated from `hardware.ini` | **done, validated on tower 121** (2026-09-23): built from its `/etc` file, flashed, `DUMP` echoed `spi=2,3,0 cs=1 en=4`, `IOIN version=0x30`, and `test hardware motor` passed `yaw_pins` / `spi_pins`; a build against a copy without `[Pins]` failed with the reason. Leonardo flash cost not yet measured. New `[Pins]` section; `firmware/tools/gen_wiring.py` generates `board_wiring.h` into the build directory from the tower's own file at build time, and refuses pins the board cannot use (RP2040 SPI0 groups, the Leonardo's fixed ICSP pins, duplicates, a board/env mismatch). No compiled-in fallback: no file or no `[Pins]` fails the build. `DUMP` echoes `cs=`/`en=` per axis and `spi=` on the RP2040; `test hardware motor` compares them with `[Pins]`. The loom tables moved to `hardware/<board>/pins.ini`. 18 generator tests (run without PlatformIO), 9 host tests |
| 6 | `NEEDCFG` understood host-side | **done**, 5 tests. `AxisState::NeedConfig` parsed from `ST` `C`, named in `DUMP` and the dashboard, and counted as telemetry arriving rather than as a dead link. The firmware enum APPENDS the state (5) rather than inserting it — inserting renumbered every later state and made a READY axis read back as ERROR | | 6 | `NEEDCFG` understood host-side | **done**, 5 tests. `AxisState::NeedConfig` parsed from `ST` `C`, named in `DUMP` and the dashboard, and counted as telemetry arriving rather than as a dead link. The firmware enum APPENDS the state (5) rather than inserting it — inserting renumbered every later state and made a READY axis read back as ERROR |
**Validated on tower 121.** The handshake was watched live on the rig, not inferred: **Validated on tower 121.** The handshake was watched live on the rig, not inferred:

View File

@ -360,10 +360,17 @@ SERIAL_PORT=/dev/serial/by-id/usb-...
./deploy.sh --monitor # watch it boot ./deploy.sh --monitor # watch it boot
``` ```
**Confirm the pin map before flashing a board that is wired to motors.** The map **Confirm the pin map before flashing a board that is wired to motors.** A
lives in `src/platform_iota.h` and a chip-select on the wrong pin writes into a chip-select on the wrong pin writes into a driver that was never meant to hear
driver that was never meant to hear it. The numbers there are the loom as built it. The pins are the `[Pins]` section of this tower's `hardware.ini`: the build
on tower 121, which is **not** the map proposed in the porting plan. reads them from `/etc/fire_gimbal_control/hardware.ini` (`firmware/tools/gen_wiring.py`)
and **fails without them**, so add the section before the first build. The
reference loom, with the table saying which wire goes where, is
[`hardware/lattepanda-iota/pins.ini`](../hardware/lattepanda-iota/pins.ini) -
the loom as built on tower 121, which is **not** the map proposed in the porting
plan. Copy it in if the tower is wired the same way. The generator refuses pins
SPI0 cannot reach, and `test hardware motor` compares the declared pins with the
ones the running firmware reports it was built with.
### Getting a factory board to accept its first flash ### Getting a factory board to accept its first flash

View File

@ -0,0 +1,17 @@
; The reference loom for a LattePanda 3 Delta tower: ATmega32U4 (Leonardo) ->
; two TMC5160 driver boards, yaw and pitch.
;
; NOT TRANSCRIBED FROM A TOWER. These are the numbers the firmware carried in
; src/platform_leonardo.h before pins moved into hardware.ini, which is what
; the Delta fleet has been running on. Nobody has yet traced the loom on a Delta
; the way tower 121's was traced, so there is no pin -> driver-pin table here.
; Check it against tower 111 and replace this paragraph with the table.
;
; SPI is on the ICSP header (MISO 14, SCK 15, MOSI 16), fixed by the hardware,
; so there are no spi_* keys; firmware/tools/gen_wiring.py refuses them, and
; refuses 14-17 as a chip-select or enable.
[Pins]
yaw_cs = 8
yaw_en = 7
pitch_cs = 1
pitch_en = 0

View File

@ -0,0 +1,27 @@
; The reference loom for a LattePanda IOTA tower: RP2040 -> one TMC5160-BOB.
; AS BUILT on tower 10.11.12.121, transcribed 2026-09-15. Not the map proposed
; in docs/hardware-porting-plan.md section 2 - that one was never wired.
;
; IOTA Function TMC5160-BOB
; 3.3V logic supply pin 1 VCC_IO (3.3 V: the RP2040 is NOT 5 V tolerant)
; GND signal ground pin 2 GND
; GP1 SPI chip select pin 3 CSN
; GP2 SPI clock pin 4 SCK
; GP3 SPI MOSI pin 5 SDI
; GP0 SPI MISO pin 6 SDO
; GP4 driver enable pin 9 DRV_ENN
;
; The SPI pins are not a free choice. The firmware drives SPI0, which reaches
; SCK {2,6,18,22}, MOSI {3,7,19,23}, MISO {0,4,16,20} and nothing else;
; firmware/tools/gen_wiring.py refuses anything outside those at build time.
; GP1 is SPI0's hardware CSn, though the driver toggles it in software anyway.
;
; The installer copies this block into hardware.ini when the tower is wired to
; this loom. A tower wired differently gets its own numbers - and the comments
; beside them should then say what each wire really goes to.
[Pins]
spi_sck = 2 ; -> BOB pin 4 SCK
spi_mosi = 3 ; -> BOB pin 5 SDI
spi_miso = 0 ; -> BOB pin 6 SDO
yaw_cs = 1 ; -> BOB pin 3 CSN
yaw_en = 4 ; -> BOB pin 9 DRV_ENN

View File

@ -48,6 +48,18 @@ struct HardwareConfig {
// Where it came from, for logging and for `test hardware` to cite. // Where it came from, for logging and for `test hardware` to cite.
std::string source_path; std::string source_path;
// [Pins] - how the MCU is wired to each driver board. The host never sends
// these: the firmware is BUILT from them (firmware/tools/gen_wiring.py reads
// the same file on the tower). Held here only so `test hardware motor` can
// compare them with the pins the running firmware reports it was built with.
// -1 = not declared.
struct Pins {
long yaw_cs = -1, yaw_en = -1, pitch_cs = -1, pitch_en = -1;
long spi_sck = -1, spi_mosi = -1, spi_miso = -1; // RP2040 only
bool declared() const { return yaw_cs >= 0 || yaw_en >= 0 || pitch_cs >= 0 ||
pitch_en >= 0 || spi_sck >= 0; }
} pins;
// True when `name` is listed in axes. An UNDECLARED rig answers true for // True when `name` is listed in axes. An UNDECLARED rig answers true for
// everything: with nothing stated there is nothing to contradict, and callers // everything: with nothing stated there is nothing to contradict, and callers
// must keep behaving exactly as they did before. // must keep behaving exactly as they did before.

View File

@ -47,6 +47,10 @@ struct DumpAxis {
// mask, so that field alone cannot tell the two apart - a bench board reads as // mask, so that field alone cannot tell the two apart - a bench board reads as
// fully configured. -1 when absent, i.e. a firmware predating the flag. // fully configured. -1 when absent, i.e. a firmware predating the flag.
long limp_home = -1; long limp_home = -1;
// The chip-select and enable pins the running image was BUILT with. -1 when
// absent, i.e. a firmware that predates the pin echo.
long cs_pin = -1;
long en_pin = -1;
// Encoder-tracking health accumulated by the firmware hold corrector. // Encoder-tracking health accumulated by the firmware hold corrector.
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
long hold_peak_dev = 0; // largest deviation seen while holding (counts) long hold_peak_dev = 0; // largest deviation seen while holding (counts)
@ -85,6 +89,9 @@ struct DumpData {
unsigned mcusr = 0; unsigned mcusr = 0;
std::string reset_text; // empty => firmware did not name it std::string reset_text; // empty => firmware did not name it
long free_ram = 0; long free_ram = 0;
// SPI pins the image was built with - RP2040 only, where they are a choice.
// -1 when absent: a Leonardo, or firmware predating the echo.
long spi_sck = -1, spi_mosi = -1, spi_miso = -1;
std::vector<std::string> reset_flags; std::vector<std::string> reset_flags;
std::vector<DumpAxis> axes; std::vector<DumpAxis> axes;
}; };

View File

@ -105,6 +105,19 @@ CheckResult checkDrivetrainAdopted(const std::string& axis, const DrivetrainExpe
// an old firmware is a version skew rather than a misconfigured tower. // an old firmware is a version skew rather than a misconfigured tower.
CheckResult checkNotLimpHome(const std::string& axis, long limp); CheckResult checkNotLimpHome(const std::string& axis, long limp);
// Is the running firmware built from the wiring hardware.ini declares?
//
// Pins are not sent over the wire: firmware/tools/gen_wiring.py compiles them in
// from the tower's hardware.ini [Pins]. So a mismatch never means a lost SET -
// it means the image was built from a different copy of that file, and the fix
// is to rebuild and re-flash. The firmware drives whatever pins it was built
// with, which on a tower wired differently means a chip-select into a driver
// that was never meant to hear it.
//
// Same shape as checkDeclaredValues: `want` false = not declared, `got` -1 =
// not echoed, i.e. a firmware predating the pin echo.
CheckResult checkPinsBuilt(const std::string& label, const DeclaredValue* values, int count);
// Does the homed travel fit entirely in positive counts? // Does the homed travel fit entirely in positive counts?
// //
// `center_count` defines where the middle of the found travel sits, so the range // `center_count` defines where the middle of the found travel sits, so the range

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "fgc/Config.h"
#include "fgc/IMotorController.h" #include "fgc/IMotorController.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
@ -28,6 +29,14 @@ public:
// way the handshake is exercised on a dev box with no rig. // way the handshake is exercised on a dev box with no rig.
explicit MockMotorController(bool needcfg = false) : needcfg_(needcfg) {} explicit MockMotorController(bool needcfg = false) : needcfg_(needcfg) {}
// The pins a real image would have been built with, from hardware.ini
// [Pins]. Echoed in DUMP in NEEDCFG mode only, which is the mock's stand-in
// for current firmware; the plain mode is a firmware predating the echo.
void setBuiltPins(const HardwareConfig::Pins& p) {
std::lock_guard<std::mutex> lock(mutex_);
pins_ = p;
}
void start() override { void start() override {
LOG_INFO << "[mock] motor controller started"; LOG_INFO << "[mock] motor controller started";
if (!needcfg_) return; if (!needcfg_) return;
@ -115,7 +124,10 @@ public:
// their meanings. See motor.h. // their meanings. See motor.h.
const int st = (needcfg_ && !configured_) ? 5 : (homed_ ? 3 : 1); const int st = (needcfg_ && !configured_) ? 5 : (homed_ ? 3 : 1);
std::ostringstream d; std::ostringstream d;
d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096\n"; d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096";
if (needcfg_ && pins_.spi_sck >= 0)
d << " spi=" << pins_.spi_sck << "," << pins_.spi_mosi << "," << pins_.spi_miso;
d << "\n";
auto axis = [&](char L, const AxisTelemetry& a) { auto axis = [&](char L, const AxisTelemetry& a) {
// In NEEDCFG mode, echo back what the host actually SET, so the // In NEEDCFG mode, echo back what the host actually SET, so the
// whole round trip - NEED, SET, DUMP, `test hardware motor` - // whole round trip - NEED, SET, DUMP, `test hardware motor` -
@ -145,7 +157,11 @@ public:
<< " hsub=0 enabled=" << (homed_ ? 1 : 0) << " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc << " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< owned << owned
<< " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"; << " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1";
const long cs = L == 'Y' ? pins_.yaw_cs : pins_.pitch_cs;
const long en = L == 'Y' ? pins_.yaw_en : pins_.pitch_en;
if (needcfg_ && cs >= 0) d << " cs=" << cs << " en=" << en;
d << "\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000" d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000" << " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
<< " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff) << " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff)
@ -227,6 +243,7 @@ public:
private: private:
bool needcfg_ = false; bool needcfg_ = false;
HardwareConfig::Pins pins_;
bool configured_ = false; bool configured_ = false;
std::vector<std::string> need_lines_; std::vector<std::string> need_lines_;
std::map<std::string, std::string> settings_; std::map<std::string, std::string> settings_;

View File

@ -306,7 +306,11 @@ 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>(cfg.features.mock_needcfg); if (mock) {
auto m = std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
m->setBuiltPins(cfg.hardware.pins);
return m;
}
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud); return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
} }

View File

@ -246,6 +246,16 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras")); cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras"));
cfg.hardware.imu = lowered(get(kv, "Hardware.imu")); cfg.hardware.imu = lowered(get(kv, "Hardware.imu"));
cfg.hardware.env = lowered(get(kv, "Hardware.env")); cfg.hardware.env = lowered(get(kv, "Hardware.env"));
{
auto& p = cfg.hardware.pins;
p.yaw_cs = getInt(kv, "Pins.yaw_cs", -1);
p.yaw_en = getInt(kv, "Pins.yaw_en", -1);
p.pitch_cs = getInt(kv, "Pins.pitch_cs", -1);
p.pitch_en = getInt(kv, "Pins.pitch_en", -1);
p.spi_sck = getInt(kv, "Pins.spi_sck", -1);
p.spi_mosi = getInt(kv, "Pins.spi_mosi", -1);
p.spi_miso = getInt(kv, "Pins.spi_miso", -1);
}
for (const auto& a : cfg.hardware.axes) for (const auto& a : cfg.hardware.axes)
if (a != "yaw" && a != "pitch") if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a + throw std::runtime_error("Hardware.axes: unknown axis '" + a +

View File

@ -1,6 +1,7 @@
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include <array> #include <array>
#include <cstdio>
#include <sstream> #include <sstream>
#include <utility> #include <utility>
@ -120,6 +121,15 @@ DumpData parseDump(const std::string& block) {
d.mcusr = static_cast<unsigned>(asInt(get(hdr, "mcusr", "0"))); d.mcusr = static_cast<unsigned>(asInt(get(hdr, "mcusr", "0")));
d.free_ram = asInt(get(hdr, "free_ram", "0")); d.free_ram = asInt(get(hdr, "free_ram", "0"));
d.reset_text = get(hdr, "reset"); d.reset_text = get(hdr, "reset");
{
// "spi=<sck>,<mosi>,<miso>". All three or none: a partial triple is a
// garbled line, and reporting two real pins beside a -1 would mislead.
const std::string spi = get(hdr, "spi");
long v[3];
if (std::sscanf(spi.c_str(), "%ld,%ld,%ld", &v[0], &v[1], &v[2]) == 3) {
d.spi_sck = v[0]; d.spi_mosi = v[1]; d.spi_miso = v[2];
}
}
d.reset_flags = bitsSet(d.mcusr, kMcusr); d.reset_flags = bitsSet(d.mcusr, kMcusr);
// Per-axis lines: "DUMP <Y|P> state=.." and "DUMP <Y|P> TMC ..". // Per-axis lines: "DUMP <Y|P> state=.." and "DUMP <Y|P> TMC ..".
@ -177,6 +187,8 @@ DumpData parseDump(const std::string& block) {
ax.sw_mode = asInt(get(m, "sw_mode", "-1")); ax.sw_mode = asInt(get(m, "sw_mode", "-1"));
ax.cfg_received = asInt(get(m, "cfg_rx", "-1"), -1); ax.cfg_received = asInt(get(m, "cfg_rx", "-1"), -1);
ax.limp_home = asInt(get(m, "limp", "-1"), -1); ax.limp_home = asInt(get(m, "limp", "-1"), -1);
ax.cs_pin = asInt(get(m, "cs", "-1"), -1);
ax.en_pin = asInt(get(m, "en", "-1"), -1);
ax.hold_corrections = asInt(get(m, "hold_corr", "0")); ax.hold_corrections = asInt(get(m, "hold_corr", "0"));
ax.hold_peak_dev = asInt(get(m, "hold_peak", "0")); ax.hold_peak_dev = asInt(get(m, "hold_peak", "0"));
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0")); ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));
@ -223,6 +235,7 @@ std::vector<std::string> formatDump(const DumpData& d) {
<< " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]" << " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]"
<< " hold_target=" << ax.hold_target << " hold_target=" << ax.hold_target
<< " has_encoder=" << (ax.has_encoder ? 1 : 0); << " has_encoder=" << (ax.has_encoder ? 1 : 0);
if (ax.cs_pin >= 0) os << " cs=" << ax.cs_pin << " en=" << ax.en_pin;
// Shown only when set. It is a fault on a tower - the axis is on the // Shown only when set. It is a fault on a tower - the axis is on the
// firmware's fallback values, not hardware.ini's - and printing // firmware's fallback values, not hardware.ini's - and printing
// "limp=0" on every healthy dump would bury it. // "limp=0" on every healthy dump would bury it.

View File

@ -206,6 +206,43 @@ CheckResult checkDeclaredValues(const std::string& axis, const DeclaredValue* va
return r; return r;
} }
CheckResult checkPinsBuilt(const std::string& label, const DeclaredValue* values, int count) {
CheckResult r;
int checked = 0, absent = 0;
std::string diff;
for (int i = 0; i < count; ++i) {
const DeclaredValue& v = values[i];
if (!v.want) continue;
if (v.got < 0) { ++absent; continue; }
++checked;
if (v.got != v.value) {
if (!diff.empty()) diff += ", ";
diff += std::string(v.name) + " declared " + std::to_string(v.value) +
", built with " + std::to_string(v.got);
}
}
if (checked == 0 && absent == 0) {
r.ran = false;
r.detail = label + ": no [Pins] declared";
return r;
}
if (!diff.empty()) {
r.pass = false;
r.detail = label + ": " + diff +
" -- the firmware was built from different wiring than hardware.ini "
"declares. Rebuild and re-flash it";
return r;
}
if (absent > 0) {
r.pass = false;
r.detail = label + ": the firmware does not report the pins it was built with, so its "
"wiring cannot be confirmed -- it predates the pin echo. Re-flash it";
return r;
}
r.detail = label + ": built with the declared pins";
return r;
}
CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel) { CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel) {
CheckResult r; CheckResult r;
if (half_travel <= 0) { if (half_travel <= 0) {

View File

@ -969,10 +969,35 @@ void TestRunner::testHwMotor(TestReport& r) {
}; };
verdict(s, std::string(name) + "_declared", verdict(s, std::string(name) + "_declared",
checkDeclaredValues(name, vals, (int)(sizeof(vals) / sizeof(vals[0])))); checkDeclaredValues(name, vals, (int)(sizeof(vals) / sizeof(vals[0]))));
// The wiring the image was built from, which no SET can change.
if (hw_.declared) {
const auto& p = hw_.declared->pins;
const bool yaw = code == 'Y';
long cs = -1, en = -1;
for (const auto& ax : d.axes)
if (ax.axis == code) { cs = ax.cs_pin; en = ax.en_pin; }
const DeclaredValue pins[] = {
{"cs", (yaw ? p.yaw_cs : p.pitch_cs) >= 0, yaw ? p.yaw_cs : p.pitch_cs, cs},
{"en", (yaw ? p.yaw_en : p.pitch_en) >= 0, yaw ? p.yaw_en : p.pitch_en, en},
};
verdict(s, std::string(name) + "_pins", checkPinsBuilt(name, pins, 2));
}
}; };
check_axis("yaw", 'Y', hw_.drive->yaw); check_axis("yaw", 'Y', hw_.drive->yaw);
if (!hw_.declared || hw_.declared->hasAxis("pitch")) check_axis("pitch", 'P', hw_.drive->pitch); if (!hw_.declared || hw_.declared->hasAxis("pitch")) check_axis("pitch", 'P', hw_.drive->pitch);
// SPI is a choice only on the RP2040, so only there is it declared and echoed.
if (hw_.declared && hw_.declared->pins.spi_sck >= 0) {
const auto& p = hw_.declared->pins;
const DeclaredValue spi[] = {
{"spi_sck", true, p.spi_sck, d.spi_sck},
{"spi_mosi", p.spi_mosi >= 0, p.spi_mosi, d.spi_mosi},
{"spi_miso", p.spi_miso >= 0, p.spi_miso, d.spi_miso},
};
verdict(s, "spi_pins", checkPinsBuilt("spi", spi, 3));
}
rollup(s); rollup(s);
r.sections.push_back(std::move(s)); r.sections.push_back(std::move(s));
} }

View File

@ -582,6 +582,25 @@ TEST_CASE("Hardware.axes parsing and membership") {
} }
} }
TEST_CASE("[Pins] is read for comparison, and absent reads as undeclared") {
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Pins.yaw_cs", "1"},
{"Pins.yaw_en", "4"},
{"Pins.spi_sck", "2"},
{"Pins.spi_miso", "0"}});
CHECK(c.hardware.pins.declared());
CHECK(c.hardware.pins.yaw_cs == 1);
CHECK(c.hardware.pins.yaw_en == 4);
CHECK(c.hardware.pins.spi_sck == 2);
CHECK(c.hardware.pins.spi_miso == 0); // GP0 is a pin, not "unset"
CHECK(c.hardware.pins.pitch_cs == -1);
auto none = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"}});
CHECK_FALSE(none.hardware.pins.declared());
}
TEST_CASE("A mount angle that cannot be a rotation is rejected") { TEST_CASE("A mount angle that cannot be a rotation is rejected") {
auto with = [](const std::string& k, const std::string& v) { auto with = [](const std::string& k, const std::string& v) {
return ConfigLoader::fromMap({{"Camera.rgb.id", "D"}, return ConfigLoader::fromMap({{"Camera.rgb.id", "D"},

View File

@ -1,6 +1,7 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/mock/MockMotorController.h"
#include <algorithm> #include <algorithm>
#include <string> #include <string>
@ -260,3 +261,61 @@ TEST_CASE("without that field the AVR decode is labelled as an assumption") {
CHECK(out.find("PORF") != std::string::npos); // still decoded CHECK(out.find("PORF") != std::string::npos); // still decoded
CHECK(out.find("assumed AVR") != std::string::npos); // but not stated as fact CHECK(out.find("assumed AVR") != std::string::npos); // but not stated as fact
} }
TEST_CASE("the pins the firmware was built with are read back from DUMP") {
const std::string text =
"DUMP BEGIN build=abc uptime=1 mcusr=0x3 reset=soft free_ram=4096 spi=2,3,0\n"
"DUMP Y state=2 hsub=0 enabled=1 lim_neg=0 lim_pos=1000000 hold_target=0 speed=1 "
"center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1 "
"cs=1 en=4\n"
"DUMP END\n";
auto d = fgc::parseDump(text);
REQUIRE(d.valid);
CHECK(d.spi_sck == 2);
CHECK(d.spi_mosi == 3);
CHECK(d.spi_miso == 0); // GP0 is a real pin, not "absent"
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].cs_pin == 1);
CHECK(d.axes[0].en_pin == 4);
CHECK(d.axes[0].has_encoder); // no longer the last field, still parsed
}
TEST_CASE("a firmware predating the pin echo reports -1, and a Leonardo has no spi") {
auto d = fgc::parseDump(kDump);
REQUIRE(d.valid);
CHECK(d.spi_sck == -1);
CHECK(d.spi_miso == -1);
for (const auto& a : d.axes) {
CHECK(a.cs_pin == -1);
CHECK(a.en_pin == -1);
}
}
TEST_CASE("a garbled spi triple is absent, not half-read") {
auto d = fgc::parseDump("DUMP BEGIN build=x uptime=1 mcusr=0x0 free_ram=1 spi=2,3\nDUMP END\n");
REQUIRE(d.valid);
CHECK(d.spi_sck == -1);
}
TEST_CASE("the mock echoes declared pins only when standing in for current firmware") {
fgc::HardwareConfig::Pins p;
p.yaw_cs = 1; p.yaw_en = 4; p.spi_sck = 2; p.spi_mosi = 3; p.spi_miso = 0;
fgc::MockMotorController current(/*needcfg=*/true);
current.setBuiltPins(p);
current.sendCommand("DUMP");
auto d = fgc::parseDump(current.lastDump());
REQUIRE(d.valid);
CHECK(d.spi_sck == 2);
CHECK(d.axes.at(0).cs_pin == 1);
CHECK(d.axes.at(0).en_pin == 4);
CHECK(d.axes.at(1).cs_pin == -1); // no pitch pins declared
fgc::MockMotorController old; // predates the echo, as real old firmware does
old.setBuiltPins(p);
old.sendCommand("DUMP");
auto o = fgc::parseDump(old.lastDump());
REQUIRE(o.valid);
CHECK(o.spi_sck == -1);
CHECK(o.axes.at(0).cs_pin == -1);
}

View File

@ -250,3 +250,37 @@ TEST_CASE("an un-homed axis is not checked") {
CHECK_FALSE(r.ran); CHECK_FALSE(r.ran);
CHECK(r.pass); CHECK(r.pass);
} }
// --- the wiring the image was built from ------------------------------------
TEST_CASE("pins built as declared pass") {
const DeclaredValue v[] = {{"cs", true, 1, 1}, {"en", true, 4, 4}};
auto r = checkPinsBuilt("yaw", v, 2);
CHECK(r.ran);
CHECK(r.pass);
}
TEST_CASE("a pin mismatch blames the build, not a lost SET") {
// Pins never travel over the wire, so "a SET was lost" would send someone
// looking in the wrong place. The only fix is a rebuild from this file.
const DeclaredValue v[] = {{"cs", true, 1, 5}, {"en", true, 4, 4}};
auto r = checkPinsBuilt("yaw", v, 2);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("cs declared 1, built with 5") != std::string::npos);
CHECK(r.detail.find("Rebuild and re-flash") != std::string::npos);
CHECK(r.detail.find("SET") == std::string::npos);
}
TEST_CASE("a firmware that does not echo its pins cannot pass") {
const DeclaredValue v[] = {{"cs", true, 1, -1}, {"en", true, 4, -1}};
auto r = checkPinsBuilt("yaw", v, 2);
CHECK(r.ran);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("predates the pin echo") != std::string::npos);
}
TEST_CASE("no [Pins] declared is not checked") {
const DeclaredValue v[] = {{"cs", false, 0, 1}};
auto r = checkPinsBuilt("yaw", v, 1);
CHECK_FALSE(r.ran);
}