From 6f88b00856274d3b5d742b2276b8bae945f2da20 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Fri, 18 Sep 2026 17:06:34 +0200 Subject: [PATCH] Write the drivetrain block at install, since the firmware now demands it --- docs/hardware-config-plan.md | 3 +- docs/hardware-porting-plan.md | 20 ++++-- docs/tower-installation.md | 22 +++++- hardware/lattepanda-iota/install.sh | 107 +++++++++++++++++++++++++++- src/core/Application.cpp | 16 +++++ 5 files changed, 158 insertions(+), 10 deletions(-) diff --git a/docs/hardware-config-plan.md b/docs/hardware-config-plan.md index 6bdba7b..4512431 100644 --- a/docs/hardware-config-plan.md +++ b/docs/hardware-config-plan.md @@ -435,11 +435,12 @@ Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-confi | 3 | `hardware.ini`: `[Board]` / `[Hardware]` typed, layered over `config.ini`, shadowed keys warned | **done**, 5 tests | | 4 | Resolution chain `--hardware` / `$FGC_HARDWARE` / `/etc/...` / beside `config.ini` | **done**, 2 tests | | 8 | `config/hardware.example.ini` documenting the shape | **done**, and the `hardware/lattepanda-iota/` setup kit now exists too (`install.sh`, `packages.txt`, three precondition files) | +| 8 | `install.sh` writes the drivetrain block | **done.** One `[Motor.]` per declared axis, with `--r-sense` / `--run-current` / `--homing-current` / `--travel-deg` / `--steps-per-rev` / `--sw-mode` for what an installer knows and commented `; TODO` lines for what nothing can probe. Needed because the firmware now refuses to move undeclared | | 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 | | 5 | Delete the `Application.cpp` speed literals | **done**, 13 tests. Decision 2 answered: speed is declared as `[Motor.] 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** | -| 6 | Config handshake — **firmware half** | **written, NOT compile-verified.** `NEEDCFG` state (`ST` code `C`), `NEED` emitted with the banner, `SET `, `USE DEFAULTS`, and the init block split so the residual-motion stop still runs at boot. Parser verified locally (30 native tests, g++); the rest type-checks against stubs on both board branches but has never been built by PlatformIO or run on hardware | +| 6 | Config handshake — **firmware half** | **done and built** on both targets 2026-09-18 (iota_rp2040 and leonardo, `./deploy.sh --compile-only`); behaviour still unproven on a running rig. `NEEDCFG` state (`ST` code `C`), `NEED` emitted with the banner, `SET `, `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`. **Behaviour on a running rig is the one check still outstanding** | | 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` | Presence reconciliation (§10) changes nothing on a tower without a `hardware.ini`: absent is diff --git a/docs/hardware-porting-plan.md b/docs/hardware-porting-plan.md index d2eca3d..1928a52 100644 --- a/docs/hardware-porting-plan.md +++ b/docs/hardware-porting-plan.md @@ -299,17 +299,25 @@ commit `aab1925`. | 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** | -| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass | +| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built.** Latest, 2026-09-18, with the config handshake: **iota_rp2040** 51716 B flash (0.6 % of 8 MB), 9108 B RAM (3.5 %); **leonardo** 22646 B flash (**79 % of 28 KB**), 939 B RAM (36.7 %). Both from `./deploy.sh --compile-only` against towers 121 and 111; 33 native tests pass (`pio test -e native`) | | 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** — needs the config plan's phase 2 first | | 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 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) | -> **Not compile-verified.** PlatformIO is not installed on the dev box and the firmware is built -> *remotely on the tower* by `firmware/deploy.sh`. What was verified locally: both branches of -> `platform.h` syntax-check against stub headers — the AVR branch under `gnu++11` (what the Arduino AVR -> core uses) and the RP2040 branch under `gnu++17` (what arduino-pico uses) — and an unrecognised -> architecture fails on the `#error` rather than compiling silently. +> **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 +> (`command_parse.cpp` and its native tests) compile and run under plain g++, and the rest +> syntax-checks against stub headers on both branches — the AVR under `gnu++11` and the RP2040 under +> `gnu++17`, with an unrecognised architecture failing on the `#error` rather than compiling silently. +> That catches typos and signatures; it says nothing about size or behaviour, so a remote build is +> still the gate. +> +> **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79 % of +> its 28 KB** (6026 B left), up from 19254 B at the phase 2 gate — that growth is everything since: +> `BOOTSEL`, `CURRENT`, the reduced homing current, `STOP` ending a homing, and the config handshake. +> RP2040 flash is a non-issue at 0.6 % of 8 MB. Deleting the drivetrain fields from `MOTOR_CONFIG` +> (config plan §14) now buys space as well as removing a duplicate definition. > > To actually build it: `cd firmware && ./deploy.sh --compile-only` compiles on the device without > flashing. That rsyncs over the tower's firmware source tree, so it is a deliberate step, not one to diff --git a/docs/tower-installation.md b/docs/tower-installation.md index cf721c8..6f8afae 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -230,8 +230,26 @@ Then prove the program finds it **with nothing in the environment** - no ### The drivetrain block -`install.sh` does not write this one - no probe can read a sense resistor off a -board - so add it by hand, one `[Motor.]` block per axis: +**`install.sh` now writes this block**, one `[Motor.]` per declared axis — +it has to, because the firmware refuses to move until it has been told these +(see the config handshake in [configuration.md](configuration.md)). What it still +cannot do is *probe* them: no reading of a board tells you its sense resistor. + +So pass what you know and it writes the values; omit anything and it writes a +commented `; TODO` line naming what to measure: + +```bash +./install.sh --stdout --axes yaw \ + --r-sense 0.075 --run-current 756 --homing-current 200 \ + --travel-deg 340 --steps-per-rev 1210778 --sw-mode 0 +``` + +A `TODO` line is **commented, not a placeholder value**, and deliberately: these +keys are numeric, and a non-numeric value makes the program refuse to start with +a message about a config key. Left absent, it starts and names exactly which keys +that axis is missing and that it will not move — the useful failure. + +The block it writes looks like this: ```ini [Motor.yaw] diff --git a/hardware/lattepanda-iota/install.sh b/hardware/lattepanda-iota/install.sh index 2547a00..a82ab41 100755 --- a/hardware/lattepanda-iota/install.sh +++ b/hardware/lattepanda-iota/install.sh @@ -20,6 +20,22 @@ # --env none (default: none) ambient sensor model, or none # --cameras rgb (default: rgb) expected [Camera.] blocks # +# The drivetrain, per axis. The firmware REFUSES TO MOVE until it has been told +# these (docs/configuration.md, the config handshake), so an axis left undeclared +# is an axis that will sit in NEEDCFG. Pass what you know; what you omit is +# written as a commented line naming what has to be measured: +# +# --r-sense 0.075 sense resistor on that axis's TMC board (ohms) +# --run-current 756 coil current for normal motion (mA RMS) +# --homing-current 200 coil current while hunting an endstop (mA RMS) +# --travel-deg 340 mechanical angle between the hard stops +# --steps-per-rev 1210778 measured full travel, in counts +# --sw-mode 0 TMC SW_MODE: 0 = no switches (StallGuard homing) +# +# These take ONE value, applied to the first axis in --axes. A second axis gets a +# commented template: nothing here can be probed, and inventing a sense resistor +# is how a driver gets cooked. +# # Other flags: # --force overwrite an existing hardware.ini (backs it up first) # --no-apt-update skip 'apt-get update' @@ -34,6 +50,10 @@ AXES="yaw,pitch" IMU="none" ENV_SENSOR="none" CAMERAS="rgb" +# Empty means "not known at install time" -> written commented out, never as a +# sentinel: these are numeric keys, and a non-numeric value makes the program +# refuse to start rather than report the axis as undeclared. +R_SENSE="" RUN_CURRENT="" HOMING_CURRENT="" TRAVEL_DEG="" STEPS_PER_REV="" SW_MODE="" DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1 VIMBA_SRC="" EXPLICIT=0 @@ -49,6 +69,12 @@ while [[ $# -gt 0 ]]; do --imu) IMU="$2"; shift ;; --env) ENV_SENSOR="$2"; shift ;; --cameras) CAMERAS="$2"; shift ;; + --r-sense) R_SENSE="$2"; shift ;; + --run-current) RUN_CURRENT="$2"; shift ;; + --homing-current) HOMING_CURRENT="$2"; shift ;; + --travel-deg) TRAVEL_DEG="$2"; shift ;; + --steps-per-rev) STEPS_PER_REV="$2"; shift ;; + --sw-mode) SW_MODE="$2"; shift ;; --vimba) VIMBA_SRC="$2"; DO_VIMBA=1; EXPLICIT=1; shift ;; --force) FORCE=1 ;; --no-apt-update) APT_UPDATE=0 ;; @@ -267,6 +293,26 @@ do_vimba() { } # ------------------------------------------------------------ hardware.ini --- +# Emit "key = value", or a commented-out line naming what has to be measured. +# +# Commented rather than a placeholder string on purpose: these keys are numeric, +# and a non-numeric value makes the program refuse to start with a message about +# a config key. Absent, it starts and reports the axis as undeclared - naming the +# missing keys and which axis will not move, which is the useful failure. +kv_or_todo() { + local key="$1" value="$2" why="$3" + if [[ -n $value ]]; then + printf '%-18s = %s\n' "$key" "$value" + return + fi + # Wrapped, and not merely for looks: ini.h sets INI_MAX_LINE to 200, and a + # longer line is SPLIT rather than truncated, so its tail becomes a bogus + # line and the whole file fails to parse. A generated file that cannot be + # read is worse than one with a gap in it. + printf '; TODO %s\n' "$key" + printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//' +} + emit_hardware_ini() { local board mcu cam fw_env board="$(probe_board)" @@ -329,6 +375,60 @@ i2c_addr = 0x44 EOF fi + # One [Motor.] block per axis. The firmware holds none of these itself + # any more - it asks, and refuses to move until answered - so an axis without + # a block here is an axis that will not turn. + # + # A value we were not given is written COMMENTED OUT, with what to measure. + # Not as a placeholder string: these keys are numeric, and a non-numeric value + # makes the program refuse to start, which is a worse failure than the axis + # reporting itself undeclared. + local ax first=1 + for ax in ${AXES//,/ }; do + ax="$(echo "$ax" | tr -d '[:space:]')" + [[ -z $ax ]] && continue + echo + echo "[Motor.$ax]" + if [[ $first -eq 1 ]]; then + kv_or_todo "r_sense_ohm" "$R_SENSE" \ + "the resistor fitted to THIS driver board - read it off the board, do not assume" + kv_or_todo "run_current_ma" "$RUN_CURRENT" \ + "coil current for normal motion, mA RMS, from the motor's nameplate" + kv_or_todo "homing_current_ma" "$HOMING_CURRENT" \ + "current while hunting an endstop. LOWER than run_current wherever the stop can be out-pushed: on this rig a screw-head stop was sheared straight past its mate at full torque" + kv_or_todo "travel_deg" "$TRAVEL_DEG" \ + "mechanical angle between the hard stops. Rough is fine to start; every bearing inherits its error proportionally until 'gimbal heading setup' supersedes it" + kv_or_todo "steps_per_rev" "$STEPS_PER_REV" \ + "measured full travel IN COUNTS - read it off a homing run. The only guard against a PHANTOM endstop" + kv_or_todo "sw_mode" "$SW_MODE" \ + "TMC SW_MODE. 0 = no physical switches (StallGuard homing); set the bits if this axis has REFL/REFR wired" + else + echo "; Nothing here can be probed, and this is not the first axis, so the" + echo "; values below are a template. Measure them on THIS axis." + kv_or_todo "r_sense_ohm" "" "sense resistor on this axis's driver board" + kv_or_todo "run_current_ma" "" "coil current for normal motion, mA RMS" + kv_or_todo "homing_current_ma" "" "current while hunting an endstop" + kv_or_todo "travel_deg" "" "mechanical angle between the hard stops" + kv_or_todo "steps_per_rev" "" "measured full travel, in counts" + kv_or_todo "sw_mode" "" "TMC SW_MODE (0 = no physical switches)" + fi + cat <<'EOF' +; Speeds are DEGREES PER SECOND, not VMAX: a register value means different +; things on different gear ratios, so it is not portable between towers. The +; host converts through counts_per_deg and sends SPEED. +scan_speed_deg_s = 8.0 +homing_speed_deg_s = 2.0 +center_speed_deg_s = 6.0 +; |XACTUAL - XENC| that means "position untrustworthy". 1500 is this mechanism's +; starting point; re-measure it whenever the homing current changes, because the +; free-running gap is coupled to it. +enc_deviation = 1500 +; Is this axis's encoder aligned to XACTUAL and usable for hold and DIAG? +has_encoder = true +EOF + first=0 + done + local c for c in ${CAMERAS//,/ }; do c="$(echo "$c" | tr -d '[:space:]')" @@ -370,7 +470,12 @@ do_hardware_ini() { board="$(probe_board)" if [[ $board != lattepanda-iota ]]; then warn "this kit is for the LattePanda IOTA; this machine reports '$board'" - [[ $PROBE_ONLY -eq 1 ]] || die "refusing to apply IOTA preconditions to a $board" + # --probe and --stdout change nothing on this machine: one reports what it + # found, the other prints the file it WOULD write. Refusing them on a + # non-IOTA only means the generator can never be exercised off a tower, + # which is how a setup kit ends up untested. + [[ $PROBE_ONLY -eq 1 || $TO_STDOUT -eq 1 ]] || + die "refusing to apply IOTA preconditions to a $board" fi if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi diff --git a/src/core/Application.cpp b/src/core/Application.cpp index e32bed0..b403421 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -1243,6 +1243,22 @@ struct Application::Impl { continue; } const bool is_yaw = need->axis == 'Y'; + + // An axis the tower says it does not have. That is not a missing + // declaration - it is a firmware built for more axes than this rig, + // and answering would configure a driver that is not there. Same + // class of fault as presence reconciliation catches, so it is named + // that way rather than as a hole in hardware.ini. + const char* axis_name = is_yaw ? "yaw" : "pitch"; + if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) { + LOG_ERROR << "firmware asked for axis " << need->axis << " (" << axis_name + << "), which this tower does not declare (axes " + << cfg.hardware.axesLabel() + << ") -- the firmware was built for more axes than the rig has; " + "rebuild it for this board rather than declaring an axis that is " + "not fitted"; + continue; + } const auto src = is_yaw ? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw, yaw_current_, cfg.yaw_soft_margin_counts)