Compare commits

..

No commits in common. "4049a85b9bdba14168fe521e306f5a7416f30e46" and "12e23a5fb648818db95a4400444758f2b2e2695d" have entirely different histories.

21 changed files with 22 additions and 482 deletions

View File

@ -69,26 +69,6 @@ cameras = rgb
device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
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]
; 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,

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
asked or answered, the axis count because it sizes the axis array. Both are still defined once in
`hardware.ini` (`[Pins]`, `[Hardware] axes`) and *generated* into `board_wiring.h` at build — one definition, realised at build
`hardware.ini` and *generated* into `firmware/src/config.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
below what I proposed last turn.
@ -402,27 +402,6 @@ Deploy host-before-firmware (A.6). Fix the `Application.cpp:1040` literals in th
existing fleet that drops yaw 50000 → 30000, a real behaviour change that wants a deliberate rig check
rather than a silent correction.
> ### ⚠️ The Delta fleet is NOT migrated, and flashing it first would strand it
>
> Checked 2026-09-23: tower **111 has no `/etc/fire_gimbal_control/hardware.ini`**.
> Only tower 121 has been migrated. This is a live hazard rather than a leftover
> chore, because the firmware half is now written: **an axis with no declared
> drivetrain sits in `NEEDCFG` and refuses to move.** Flashing 111 with current
> firmware today would leave it unable to home, with nothing wrong that an
> operator could see on the board.
>
> The ordering rule above is what prevents it, and it is not advisory:
>
> 1. Write 111 a `hardware.ini` — `hardware/lattepanda-iota/install.sh --stdout`
> generates the shape; the Delta's own values come from its `config.ini`.
> 2. Deploy the **host** and run `test hardware`. It must pass on the old
> firmware, which is the point of the `_current`/`_declared` metrics reporting
> an absent field as unconfirmable rather than as a mismatch.
> 3. Only then flash the firmware, and re-run `test hardware`.
>
> Until step 1 is done for every Delta tower, phase 1's gate — "`test hardware`
> green on every existing tower" — is **not met**, whatever 121 reports.
## 14. Firmware changes, collected
- **Per board** → `platform_leonardo.h` / `platform_iota.h`: pin map plus the three AVR-isms
@ -466,7 +445,6 @@ 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 | 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 | 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 |
**Validated on tower 121.** The handshake was watched live on the rig, not inferred:

View File

@ -133,13 +133,11 @@ size or override it, or the filesystem/OTA layout will be wrong.
>
> So the **first** flash of a factory board needs the REPL trick or a physical
> BOOTSEL press — one time per board, and worth knowing before someone drives to
> a mast. **That one is still true.**
> a mast. The **update** case is the one that still has no answer: our firmware
> honours the 1200-baud reset but returns to the application, so a tower cannot
> currently be re-flashed over the network at all.
>
> The **update** case was the one that had no answer at the time of that table:
> our firmware honoured the 1200-baud reset but returned to the application, so a
> tower could not be re-flashed over the network at all.
>
> **Since fixed by a `BOOTSEL` serial command** calling arduino-pico's
> **Fixed by a `BOOTSEL` serial command** calling arduino-pico's
> `rp2040.rebootToBootloader()` — implemented and proven on tower 121 the same
> day. The full loop now runs with nobody at the board:
>
@ -297,7 +295,7 @@ commit `aab1925`.
| Phase | Item | State |
|---|---|---|
| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and solved** (§2). Both researched paths turned out not to work on the board; the answer was a `BOOTSEL` serial command, proven on tower 121 and now sent automatically by `firmware/deploy.sh` before every RP2040 upload. The **first** flash of a factory board still needs the MicroPython REPL or a physical BOOTSEL press — once per board |
| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command |
| 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` |
| 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **done** |
| 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** |
@ -305,7 +303,7 @@ commit `aab1925`.
| 2 | Per-board pin map + `NUM_AXES` in `platform_leonardo.h` / `platform_iota.h`, as-built wiring | **done** — compile-time, not yet generated from `hardware.ini` |
| 2 | Pins + `NUM_AXES` generated from `hardware.ini` | **not started, no longer blocked** — the config plan it waited on is complete. The last open phase-2 item |
| 3 | Yaw homing on the real drivetrain | **done** — the endstop was being sheared through at full torque; homing at 189 mA holds. See below |
| 1 | Phase 1 in full — `hardware.ini`, the config handshake, presence reconciliation, `test hardware` | **built and validated on 121, NOT rolled out.** [hardware-config-plan.md](hardware-config-plan.md) is complete, but tower **111 has no `hardware.ini` at all** (checked 2026-09-23), so the gate ("`test hardware` green on every existing tower") is not met. Flashing a Delta before writing it one would strand the axis in `NEEDCFG` — see the migration warning in [the config plan §13](hardware-config-plan.md#13-migration) |
| 1 | Phase 1 in full — `hardware.ini`, the config handshake, presence reconciliation, `test hardware` | **done.** [hardware-config-plan.md](hardware-config-plan.md) is complete and validated on tower 121; the gate ("`test hardware` green on every existing tower") is met on 121, and still wants running against the Delta fleet |
> **Where the firmware gets built.** PlatformIO is not installed on the dev box; the firmware is built
> *remotely on a tower* by `firmware/deploy.sh --compile-only`. Locally, the Arduino-free sources

View File

@ -31,7 +31,7 @@ doctest unit-test suite (`ctest`).
| 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. |
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`. Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **Still open on the Delta fleet**, which has not had the kit applied. |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. |
## Camera acquisition (USB3) — root cause + approach
@ -100,18 +100,6 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
and real motor captured 15/15 waypoints, one software-triggered frame each, 0 dropped and 0 stalls. What
this does **not** cover is image *content* — the lens was covered, so exposure/blur behaviour on a real
scene remains open (#15–#17).
- **Log rotation verified on the rig (2026-09-23); pruning is not.** With
`[Logging] file_max_mb = 1` on tower 121, a segment closed at 1048513 bytes against
the 1048576 cap — rotating *before* the write that would cross it, on a whole line —
and the next segment re-emitted the header. Retention (`file_keep`) needs a fourth
segment to observe a deletion, roughly three hours at that cap, and was not run.
`tests/test_filelogsink.cpp` covers it directly.
- **The positive-travel guard has never fired in anger.** `checkTravelFitsPositive()`
catches a `center_count` smaller than half the homed travel, which puts the negative
end below zero. It was tested against real numbers and shipped *after* 121's centre
was raised to 1000000, so no tower has ever tripped it. Whether the TMC5160 actually
misbehaves below zero is still not established — see the note in
`include/fgc/HardwareChecks.h`.
- **Makefile parity** is moot: the Makefile was removed in favour of CMake, and the full
`WITH_VIMBA=ON WITH_MQTT=ON` CMake build now runs on the device.
- **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a

View File

@ -18,15 +18,6 @@ Keep entries dated; move shipped items into the reference docs.
(peak/mean CS, SG, PWM during a move) so the friction/balance/homing-time
metrics come off the firmware clock instead of host `ST` sampling; plus a
`PING`/`PONG` link-health command for `test system link`.
- ⚠️ **This collides with AVR flash headroom.** Three new commands are the
kind of growth the ATmega32U4 no longer has room for — it sits at **79.8 %
of its 28 KB** (5828 B free, measured 2026-09-23), up from 19254 B at the
phase 2 gate. The RP2040 is a non-issue at 0.6 % of 8 MB. So either these
land RP2040-only behind the `platform_*.h` split, or the Delta fleet gets a
deliberate answer on flash first — `PROGMEM` for the string tables, dropping
a command, or accepting that the Delta is feature-frozen. Note §14 of the
config plan *cost* 222 B rather than freeing any, so the obvious saving has
already been spent. See [hardware-porting-plan.md](hardware-porting-plan.md).
## Planned components & integrations

View File

@ -54,7 +54,7 @@ While a test runs, interfering commands (moves, homing, a second test, capture,
| `host` | `memory` | available RAM / swap (`/proc/meminfo`) |
| `host` | `load` | load average vs CPU count |
| `hardware` | `axes` | the axes `[Hardware] axes` declares appear in telemetry, **and no others** |
| `hardware` | `motor` | `DUMP` echoes **everything** `hardware.ini` declares — current, `steps_per_rev`, `enc_deviation`, `sw_mode` and all three speeds — catching a stale flash, a lost `SET`, or an axis that never left `NEEDCFG`. Also checks the axis is not on the firmware's **limp-home** fallback, which `USE DEFAULTS` leaves looking fully configured |
| `hardware` | `motor` | `DUMP` echoes **everything** `hardware.ini` declares — current, `steps_per_rev`, `enc_deviation`, `sw_mode` and all three speeds — catching a stale flash, a lost `SET`, or an axis that never left `NEEDCFG` |
| `hardware` | `imu` | a declared IMU is connected and a sample arrives |
| `hardware` | `env` | a declared ambient sensor sits on a **real I2C** adapter (not SMBus-only) and returns a CRC-valid reading |
| `hardware` | `camera` | every configured camera opened; `usbfs_memory_mb` and USB3 LPM hold |
@ -99,21 +99,12 @@ Three rules hold across every leaf:
look identical from the sensor's side, and the distinction is what
known-issues #2 cost to learn.
The `motor` leaf reports three metrics per axis. `<axis>_not_limp` asks where the
values came from; `<axis>_current` covers `GLOBALSCALER`/`IRUN`/`homing IRUN`; and
`<axis>_declared` covers everything else the host owns. All three treat an
**absent** field as unconfirmable rather than as a mismatch — comparing against a
missing value would report "firmware has -1", which reads as a wrong setting and
sends someone to re-send a command that was never the problem.
`<axis>_not_limp` runs first because it explains the other two. `USE DEFAULTS` is
the deliberate bench escape from `NEEDCFG`, and it fills the firmware's `cfg_rx`
mask completely — so an axis running limp-home values leaves `NEEDCFG`, reports
`READY`, and passes every state check while running numbers that describe no real
drivetrain. Without this metric the only symptom is `<axis>_declared` listing six
mismatches, which reads as "host and firmware disagree" and sends someone to
re-send values that were never sent at all. The fix is a host restart, which
re-sends the whole declared block.
The `motor` leaf reports two metrics per axis. `<axis>_current` covers
`GLOBALSCALER`/`IRUN`/`homing IRUN`; `<axis>_declared` covers everything else the
host owns. Both treat an **absent** field as unconfirmable rather than as a
mismatch — comparing against a missing value would report "firmware has -1", which
reads as a wrong setting and sends someone to re-send a command that was never the
problem.
The `motor` leaf is the one worth running after every flash: `GLOBALSCALER` and
`IRUN` are **write-only** on the TMC5160, so the `DUMP` echo is the only evidence

View File

@ -360,17 +360,10 @@ SERIAL_PORT=/dev/serial/by-id/usb-...
./deploy.sh --monitor # watch it boot
```
**Confirm the pin map before flashing a board that is wired to motors.** A
chip-select on the wrong pin writes into a driver that was never meant to hear
it. The pins are the `[Pins]` section of this tower's `hardware.ini`: the build
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.
**Confirm the pin map before flashing a board that is wired to motors.** The map
lives in `src/platform_iota.h` and a chip-select on the wrong pin writes into a
driver that was never meant to hear it. The numbers there are the loom as built
on tower 121, which is **not** the map proposed in the porting plan.
### Getting a factory board to accept its first flash

View File

@ -1,17 +0,0 @@
; 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

@ -1,27 +0,0 @@
; 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,18 +48,6 @@ struct HardwareConfig {
// Where it came from, for logging and for `test hardware` to cite.
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
// everything: with nothing stated there is nothing to contradict, and callers
// must keep behaving exactly as they did before.

View File

@ -42,15 +42,6 @@ struct DumpAxis {
// Bitmask over the firmware's CfgKey: which required keys have arrived.
// -1 when absent.
long cfg_received = -1;
// Is the axis running the firmware's limp-home fallback rather than anything
// this host declared? USE DEFAULTS fills cfg_received with the whole required
// 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.
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.
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
long hold_peak_dev = 0; // largest deviation seen while holding (counts)
@ -89,9 +80,6 @@ struct DumpData {
unsigned mcusr = 0;
std::string reset_text; // empty => firmware did not name it
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<DumpAxis> axes;
};

View File

@ -88,36 +88,6 @@ struct DrivetrainEcho {
CheckResult checkDrivetrainAdopted(const std::string& axis, const DrivetrainExpectation& want,
const DrivetrainEcho& got);
// Is the axis running the host's configuration, or the firmware's limp-home
// fallback?
//
// `USE DEFAULTS` is the deliberate bench escape from NEEDCFG, and it fills the
// firmware's cfg_rx mask completely - so an axis on limp-home values reports as
// fully configured and passes every state check. What gives it away is the DUMP
// `limp` flag, which says where the values came from rather than whether they
// arrived.
//
// checkDeclaredValues() would also catch this, but only as a list of mismatched
// numbers, which reads as "the host and firmware disagree" and sends someone to
// re-send them. The truthful diagnosis is that nobody ever sent any.
//
// `limp` < 0 means the firmware predates the flag: reported as not-run, because
// an old firmware is a version skew rather than a misconfigured tower.
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?
//
// `center_count` defines where the middle of the found travel sits, so the range

View File

@ -1,6 +1,5 @@
#pragma once
#include "fgc/Config.h"
#include "fgc/IMotorController.h"
#include "fgc/Logger.h"
@ -29,14 +28,6 @@ public:
// way the handshake is exercised on a dev box with no rig.
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 {
LOG_INFO << "[mock] motor controller started";
if (!needcfg_) return;
@ -124,10 +115,7 @@ public:
// their meanings. See motor.h.
const int st = (needcfg_ && !configured_) ? 5 : (homed_ ? 3 : 1);
std::ostringstream d;
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";
d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096\n";
auto axis = [&](char L, const AxisTelemetry& a) {
// In NEEDCFG mode, echo back what the host actually SET, so the
// whole round trip - NEED, SET, DUMP, `test hardware motor` -
@ -157,11 +145,7 @@ public:
<< " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< owned
<< " 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";
<< " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
<< " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff)
@ -243,7 +227,6 @@ public:
private:
bool needcfg_ = false;
HardwareConfig::Pins pins_;
bool configured_ = false;
std::vector<std::string> need_lines_;
std::map<std::string, std::string> settings_;

View File

@ -306,11 +306,7 @@ struct Application::Impl {
std::unique_ptr<IMotorController> makeMotor() {
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
if (mock) {
auto m = std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
m->setBuiltPins(cfg.hardware.pins);
return m;
}
if (mock) return std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
}

View File

@ -246,16 +246,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras"));
cfg.hardware.imu = lowered(get(kv, "Hardware.imu"));
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)
if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a +

View File

@ -1,7 +1,6 @@
#include "fgc/DumpParser.h"
#include <array>
#include <cstdio>
#include <sstream>
#include <utility>
@ -121,15 +120,6 @@ DumpData parseDump(const std::string& block) {
d.mcusr = static_cast<unsigned>(asInt(get(hdr, "mcusr", "0")));
d.free_ram = asInt(get(hdr, "free_ram", "0"));
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);
// Per-axis lines: "DUMP <Y|P> state=.." and "DUMP <Y|P> TMC ..".
@ -186,9 +176,6 @@ DumpData parseDump(const std::string& block) {
ax.center_speed = asInt(get(m, "center_speed", "-1"));
ax.sw_mode = asInt(get(m, "sw_mode", "-1"));
ax.cfg_received = asInt(get(m, "cfg_rx", "-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_peak_dev = asInt(get(m, "hold_peak", "0"));
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));
@ -235,11 +222,6 @@ std::vector<std::string> formatDump(const DumpData& d) {
<< " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]"
<< " hold_target=" << ax.hold_target
<< " 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
// firmware's fallback values, not hardware.ini's - and printing
// "limp=0" on every healthy dump would bury it.
if (ax.limp_home > 0) os << " LIMP-HOME (not hardware.ini's values)";
out.push_back(os.str());
}
for (const char* k : {"GCONF", "CHOPCONF", "XACTUAL", "X_ENC", "XTARGET", "VACTUAL"}) {

View File

@ -79,28 +79,6 @@ CheckResult checkI2cFunctionality(unsigned long funcs) {
return r;
}
CheckResult checkNotLimpHome(const std::string& axis, long limp) {
CheckResult r;
if (limp < 0) {
r.ran = false;
r.detail = axis +
": firmware does not report the limp flag, so whether it is running "
"hardware.ini's values or its own fallback cannot be told apart here";
return r;
}
r.pass = (limp == 0);
r.detail = r.pass
? axis + ": running the host's declared values"
: axis +
": running LIMP-HOME values, which describe no real drivetrain -- slow "
"speeds, a reduced search current and a placeholder travel. Something "
"sent USE DEFAULTS, so hardware.ini is NOT in force however healthy the "
"axis looks. Restart the host to re-send it";
return r;
}
CheckResult checkDrivetrainAdopted(const std::string& axis, const DrivetrainExpectation& want,
const DrivetrainEcho& got) {
CheckResult r;
@ -206,43 +184,6 @@ CheckResult checkDeclaredValues(const std::string& axis, const DeclaredValue* va
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 r;
if (half_travel <= 0) {

View File

@ -828,7 +828,6 @@ long echoSwMode(const DumpData& d, char c) { return axisField(d, c, &DumpA
long echoHomingSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::homing_speed); }
long echoCenterSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::center_speed); }
long echoSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::speed); }
long echoLimp(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::limp_home); }
// The VMAX a declared deg/s becomes on this axis, or 0 when it cannot be formed.
// Mirrors Application's fallback: the live calibration when it is trustworthy,
@ -926,12 +925,6 @@ void TestRunner::testHwMotor(TestReport& r) {
}
auto check_axis = [&](const char* name, char code, const AxisDrivetrainConfig& cfg) {
// First, because it explains the rest. An axis on limp-home values will
// fail every comparison below, and that list of mismatched numbers reads
// as "host and firmware disagree" - sending someone to re-send values
// that were never sent at all.
verdict(s, std::string(name) + "_not_limp", checkNotLimpHome(name, echoLimp(d, code)));
DrivetrainExpectation want;
if (cfg.run_current_ma > 0.0 && cfg.r_sense_ohm > 0.0) {
const auto plan = planTmcCurrent(cfg.r_sense_ohm, cfg.run_current_ma,
@ -969,35 +962,10 @@ void TestRunner::testHwMotor(TestReport& r) {
};
verdict(s, std::string(name) + "_declared",
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);
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);
r.sections.push_back(std::move(s));
}

View File

@ -582,25 +582,6 @@ 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") {
auto with = [](const std::string& k, const std::string& v) {
return ConfigLoader::fromMap({{"Camera.rgb.id", "D"},

View File

@ -1,7 +1,6 @@
#include <doctest/doctest.h>
#include "fgc/DumpParser.h"
#include "fgc/mock/MockMotorController.h"
#include <algorithm>
#include <string>
@ -261,61 +260,3 @@ 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("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

@ -41,35 +41,6 @@ TEST_CASE("an SMBus-only adapter is caught before it looks like a dead sensor")
CHECK(smbus.detail.find("i2cdetect -F") != std::string::npos);
}
TEST_CASE("a tower on limp-home values is a fault, however healthy it looks") {
// The case this exists for: USE DEFAULTS fills the firmware's cfg_rx mask
// completely, so the axis leaves NEEDCFG, reports READY and passes every
// state check - while running values that describe no real drivetrain.
auto r = checkNotLimpHome("yaw", 1);
CHECK(r.ran);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("LIMP-HOME") != std::string::npos);
// It must send the reader to the cause, not to the symptom. "Re-send the
// values" is the wrong instruction when nobody ever sent any.
CHECK(r.detail.find("USE DEFAULTS") != std::string::npos);
CHECK(r.detail.find("Restart the host") != std::string::npos);
}
TEST_CASE("an axis on the host's own values passes") {
auto r = checkNotLimpHome("yaw", 0);
CHECK(r.ran);
CHECK(r.pass);
}
TEST_CASE("a firmware with no limp flag is a version skew, not a passing tower") {
// Absent must not read as limp=0. A firmware predating the flag genuinely
// cannot answer the question, and claiming PASS here would assert something
// the wire never said - the same -1-is-not-zero rule the current echo uses.
auto r = checkNotLimpHome("yaw", -1);
CHECK_FALSE(r.ran);
CHECK(r.detail.find("cannot be told apart") != std::string::npos);
}
TEST_CASE("an undeclared axis has no drivetrain skew to find") {
DrivetrainExpectation want; // declared == false
DrivetrainEcho got; // all -1
@ -250,37 +221,3 @@ TEST_CASE("an un-homed axis is not checked") {
CHECK_FALSE(r.ran);
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);
}