fwt_software/docs/hardware-config-plan.md

530 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Hardware Configuration Plan
Splitting `config.ini` by lifetime, introducing an assembly-time `hardware.ini`, and letting the firmware
ask the host for the parameters it needs instead of carrying its own copies.
Draft for review, 2026-09-11. Covers `software/` and `firmware/`.
**This work stands on its own.** It fixes a live bug (§5), removes a class of silent degradation (§10)
and is worth doing whether or not the LattePanda IOTA port goes ahead. It is also a **prerequisite** for
that port — see [hardware-porting-plan.md](hardware-porting-plan.md).
## 0. The bindings this has to capture
Read live from the tower (`ggs@10.11.12.111`), not from the docs — the board is a **LattePanda 3 Delta**,
not the Sigma `docs/deployment.md:3` claims:
| Function | Binding | Backing hardware |
|---|---|---|
| Motor MCU | `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00` | USB `2341:8036`, onboard ATmega32U4 |
| IMU (Xsens MTi) | `/dev/ttyS0` | onboard RS-232 header |
| Ambient (SHT41) | `/dev/i2c-2` @ `0x44` | Synopsys DesignWare LPSS I2C |
| Camera | `DEV_1AB22C049CD1` | USB3 `1ab2:0001` Alvium 1800 U-2040c |
| Modem | `/dev/ttyUSB0-3` | USB `2c7c:0801` Quectel RM520N-GL, M.2 B-key |
Multiple rigs are in production, **mostly without an IMU**, so every change here has to be invisible on
rollout across a live mixed fleet.
---
## 1. Why split at all
The organising principle is **who writes this, and how often**:
| | Author | Changes | Goes to |
|---|---|---|---|
| **Assembly** | a human, once | only if someone physically changes the machine | `hardware.ini` |
| **Operation** | a human, continuously | whenever you tune | `config.ini` |
| **Calibration** | the program, on operator confirmation | from time to time — it assigns real-world orientation and drifts out of true | `config.ini` |
Three things it buys:
1. **Building a new tower is writing one file.**
2. **It survives deploys structurally.** `deploy.sh:234` keeps `config.ini` out of rsync by an exclude
rule someone has to maintain; `deploy.sh:227` records that `--delete` already destroyed a freshly
exported camera XML once. A file outside the deploy tree cannot be lost that way.
3. **It gives every fact exactly one home** — which is A.5 and A.6, and the largest part of this work.
## 2. Two files
```
/etc/fire_gimbal_control/hardware.ini # assembly — human, frozen, never deployed
<deploy tree>/config.ini # operation + calibration
```
Calibration stays in `config.ini`: it changes over time, so it is not assembly data. `--push-config`
stays as it is — a dev convenience for the tower under development.
## 3. What goes in `hardware.ini`
```ini
[Board]
model = lattepanda-3-delta ; or: lattepanda-iota
firmware_env = leonardo ; or: iota_rp2040
[Hardware]
axes = yaw, pitch ; or: yaw
imu = none ; or: mti — most of the fleet is 'none'
env = sht41 ; or: none
cameras = rgb
[Serial]
device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
[IMU]
device = /dev/ttyS0
[Env]
i2c_device = /dev/i2c-2
i2c_addr = 0x44
[Camera.rgb]
id = DEV_1AB22C049CD1
; ── Drivetrain. A different motor or gear ratio changes all of these. ──
[Motor.yaw]
steps_per_rev = 177000 ; full mechanical travel reference
global_scaler = 80 ; TMC GLOBALSCALER — motor current
enc_deviation = 1500 ; yaw homing stall threshold (see A.5)
sw_mode = 0x08A0 ; hardware stop switches off (no physical endstops)
normal_speed = 30000
homing_speed = 12000
center_speed = 25000
soft_margin = 5000
[Motor.pitch]
steps_per_rev = 500000
global_scaler = 50
sw_mode = 0x08A3 ; endstops enabled
normal_speed = 60000
homing_speed = 150000
center_speed = 60000
soft_margin = 5000
```
`tower_name` belongs here too — assigned once, at assembly.
## 4. How the files find each other
**Recommendation: a fixed location, not a pointer in `config.ini`.**
```
--hardware <path> # explicit, for dev and testing
$FGC_HARDWARE # service environment
/etc/fire_gimbal_control/hardware.ini # the normal answer on a tower
<dir of config.ini>/hardware.ini # fallback, so a dev checkout just works
```
with the resolved path logged at startup. **`/etc/fire_gimbal_control/` is already your convention** —
`deployment.md §4` puts `credentials.env` there.
## 5. The duplication, and the bug it is already causing
> ### ⚠️ The host is silently undoing the firmware's speed reduction
> `Application.cpp:1040–1041`, at the end of every `--init`, unconditionally sends:
> ```
> SPEED Y 50000
> SPEED P 150000
> ```
> Hardcoded in host source. Meanwhile `firmware/src/config.h` sets `normal_speed` to **30000** for yaw —
> commented *"gentler top speed (was 50000)"* — and **60000** for pitch — *"(was 150000)"*.
>
> **The host sends exactly the old values the firmware deliberately moved away from.** The reduction has
> been clobbered on every init since it was made. Live on the fleet today.
Today the same fact exists in up to three places: `hardware.ini` (proposed), the firmware's compile-time
`MOTOR_CONFIG` table, and host source. The fix is not to synchronise them but to **delete two of them**.
### The gear ratio reaches further than speeds
Three more values change with the new yaw drivetrain, and none is a speed:
- **`enc_deviation` is the one that bears on damage.** Yaw homing detects the endstop when
`|XACTUAL − XENC|` exceeds it. `firmware/docs/hardware-notes.md` records the free-running gap as ~500
against a limit of 1500, and that *"lower = snappier (less grind)"*. **A different gear ratio changes
that free-running gap**, so it must be re-measured on the new tower — too low and homing false-triggers
mid-travel, too high and it grinds longer before noticing. Tuning homing speed alone solves half of it.
- **`steps_per_rev`** is what `HOMING_MIN_TRAVEL_SLACK` validates each home against.
- **`yaw_counts_per_deg` must be recalibrated.** The 983.33 default is derived from
`steps_per_rev = 177000`; on a different ratio it is wrong, so `gimbal heading setup` is mandatory.
## 6. The config handshake — the firmware asks
**The firmware should declare what it needs and refuse to move until it has it.** That removes the
firmware's parameter table as a competing definition, rather than trying to keep it in sync.
### Why this is a small change, not a new architecture
Both halves already exist:
- **The firmware already treats some parameters as host-owned.** `motor.h:70` reads *"host-owned homing
parameters (config.ini, sent as CENTER / MARGIN)"*.
- **The host already verifies the firmware adopted them.** `Application.cpp:1099` errors with *"the
firmware did not adopt CENTER (is it older than this host?) … Refusing to derive travel limits;
headings would be wrong"*, and `:1108` warns *"firmware reported no soft_margin (update firmware)"*.
So the pattern, the direction of ownership, and even the version-skew handling are established. What is
missing is that the firmware **guesses** when the host says nothing, instead of asking.
### The sequence
1. **Boot: pins and SPI up, then stop the motors.** Unconditional, no config needed — see the safety
note below.
2. **State `NEEDCFG`.** Motors stay disabled; `HOME` / `MOVE` / `ENABLE` are rejected with
`ERR need config`. Reported in `ST` and `DUMP`, so the dashboard shows *why* nothing moves.
3. **On host connection, ask.** `main.cpp` already reprints the boot banner whenever the host opens the
port — the natural hook. Emit alongside it:
```
BOOT build=abc123
NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed center_speed center margin
NEED P ...
```
4. **Host answers from `hardware.ini`** with a generic `SET <axis> <key> <value>`. `SPEED`, `CENTER` and
`MARGIN` stay as aliases so old hosts and hand-typed sessions keep working.
5. **When an axis's required set is complete**, the firmware applies the parameterised init block and
moves that axis to `ST_RESET`. `DUMP` reports every value it is holding, so the existing adoption
check generalises from "did CENTER land?" to "does the firmware hold what `hardware.ini` says?" —
which is `test hardware motor` in A.9.
### ⚠️ The safety constraint this must not break
`applyInitBlock` ends with, and `hardware-notes.md` explains why:
```cpp
tmc_.write(TMC_VMAX, 0);
tmc_.write(TMC_RAMPMODE, RAMP_VELOCITY_POS); // stop residual motion
tmc_.write(TMC_GSTAT, 0x07); // clear power-up latches
```
*"The TMC has its own supply and keeps running across an MCU-only reset (e.g. a re-flash), so it may
still be executing a velocity command from the previous session."* If the firmware waited for host
config before touching the driver, **a re-flash could leave the motor grinding until the host connects.**
So split the init block in two:
| Phase | Contents | When |
|---|---|---|
| **Safety init** | `VMAX=0` + velocity mode, `GSTAT` clear, and the constant register writes | at boot, unconditionally |
| **Parameterised init** | `GLOBALSCALER`, `XACTUAL = center`, `SW_MODE`, `ENC_DEVIATION` | once `NEEDCFG` is satisfied |
Those four are exactly the writes in `applyInitBlock` that read from `cfg_` or a tunable constant; the
rest are already fixed values.
### Standalone / bench operation
`hardware-notes.md`'s bring-up checklist drives the firmware by hand over `deploy.sh --monitor`, so a
firmware that refuses to move without a host would break it. Add an explicit
**`USE DEFAULTS`** command that loads a built-in **limp-home** set and logs loudly.
The distinction matters: limp-home values are **deliberately conservative — slow speeds, generous
margins — and never intended to match any real tower.** They are not a second definition of the
drivetrain, which is what today's `normal_speed = 30000` is. Nothing reaches production on them, and
`test hardware motor` fails if a tower is running them.
**Prefer this to a timeout fallback.** A firmware that waits *n* seconds and then adopts defaults means a
slow host silently produces a tower running on the wrong values — precisely the silent-degradation class
this whole design is removing. Make it an explicit act.
### What remains compile-time
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
time instead of over the wire. Everything else becomes runtime, which shrinks the build-time set far
below what I proposed last turn.
### Rollout order matters
A new firmware sitting in `NEEDCFG` in front of an old host would never be configured. So: **host first,
then firmware.** The host should treat a firmware that never sends `NEED` as an old one and fall back to
today's push-and-verify path, logging that it did.
## 7. Build identity in the TUI
No version negotiation — firmware and software are deployed in tandem. This section is display only.
### What already exists
Almost all of it. Both halves stop one step short.
**Firmware build id — travels end to end and is already on screen:**
- `firmware/platformio.ini` injects `GIT_REV` at build time, *"so the flashed firmware carries a build
identity"*, from `git describe --always --dirty`.
- The firmware reports it in `BOOT build=<rev>` (`main.cpp:39`) and `DUMP BEGIN build=<rev>` (`:103`).
- The host parses it into `DumpData.build` (`DumpParser.h:46`) and **already renders it in the gimbal
pane** — `TuiUi.cpp:999` draws `build <rev> up <ms> reset:<x>` on that overlay's header row.
Nothing to do here. It works today.
**Software build id — the slot exists and is filled with a placeholder:**
- `HeaderView` has a `build` field (`UiSnapshot.h:185`), and `TuiUi.cpp:1324` already renders
`text("build " + s.header.build) | dim` in the main header, right after the tower name — exactly where
you suggested putting it.
- `deploy.sh:212` computes `GIT_REV="$(git describe --always --dirty)"` and `:288` passes it into the
remote CMake invocation.
- But **`CMakeLists.txt` never reads `$ENV{GIT_REV}`**, so the value is passed and dropped, and
`Application.cpp:610` hardcodes `s.header.build = "dev"`.
Two edits close it:
1. `CMakeLists.txt` reads `$ENV{GIT_REV}` into a compile definition, defaulting to `"unknown"` — exactly
as `firmware/src/main.cpp:14` already does.
2. `Application.cpp:610` uses it instead of the `"dev"` literal.
```
FIREWATCH TOWER NorthRidge build 1a572cb LIVE up 412s
```
### One log line at startup
So an unattended tower records what it is running, where `journalctl` will find it. There is already a
house pattern — `Application.cpp:526` logs the IMU's identity the same way:
```
INFO Firmware: build a1b2c3d (from the BOOT banner, once, at connection)
```
Identity only. Nothing compares it to anything.
> **Two small notes.** The repo has **no tags**, so `git describe --always --dirty` currently yields a
> bare hash (`1a572cb-dirty`) — an honest build identity, but not a version. Tagging releases would make
> the same line read `v1.4.0-2-g1a572cb` at no extra cost, if you ever want it. And `$ENV{GIT_REV}` is
> read at CMake **configure** time, so a plain `cmake --build` after edits keeps the configure-time
> value; `deploy.sh:288` reconfigures on every deploy, so the deployed path stays fresh — and this
> matches how the firmware already behaves.
### Why no compatibility check
A maintained protocol number is one more thing to remember to bump, and a **forgotten bump is worse than
no number** — it asserts a compatibility that was never verified. The cases it would have caught are
already covered, behaviourally:
- **The handshake describes its own generation.** A firmware that never sends `NEED` (§6) is an old
one; the host falls back to today's push-and-verify path on that evidence alone. No number needed.
- **`test hardware motor`** (§11) asks the firmware to `DUMP` and confirms it is actually holding the
`hardware.ini` values — what the firmware *did*, not what it *claimed*.
- **`Application.cpp:1101` and `:1108`** already detect the two concrete incompatibilities anyone has hit
so far, and keep doing so unchanged.
## 8. Generated at install, not hand-written from scratch
```
hardware/
lattepanda-3-delta/
packages.txt # apt packages
preconditions/ # udev rules, sysctl.d, kernel-cmdline snippet
install.sh # apply preconditions; PROBE the device and emit hardware.ini
NOTES.md # quirks, verified-on date
lattepanda-iota/
...
```
`install.sh` fills in what it can detect — board model from DMI, the serial `by-id` path, the working
I2C bus (probed with a real SHT41 measurement, since `i2cdetect` false-negatives on the SHT4x), the
camera's `DEV_` id — and leaves a human to enter `axes`, `imu`, `env` and the drivetrain block, which no
probe can distinguish from a *fault*. Which is the whole of §10.
## 9. Calibration write-back
### Already implemented — the confirmation prompt
Nothing is written without a yes:
- `Application.cpp:944` raises the activity-strip prompt *"Save the new [Motor] calibration to config as
the default? (y / n)"* whenever `calib_save_pending_` is set.
- `Application.cpp:1260` / `:1726` set it after `gimbal heading setup` / `heading fit` / `pitch setup`.
- `Application.cpp:1847` implements `save` and `discard` as **commands**, so the answer works over the
TUI keypress (`TuiUi.cpp:1599`), stdin, or MQTT.
Worth checking on the rig: the headless/systemd path should log the values and expire, not hang.
### To add — keep the previous value as a comment
`updateIniSectionKeys` replaces a matching line **in place**, so the old value is lost. Keep exactly one
generation:
```ini
[Motor]
;prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal heading setup
yaw_heading_at_center = 127.4
```
Two rules keep it tidy: **only record a key whose value actually changed** (`saveMotorGeometry` rewrites
all eight every save, so without this the file gains eight lines of noise per calibration), and
**replace, don't accumulate** — an existing `;prev` for that key is overwritten, so the file grows once
and then holds steady. Reverting is uncommenting; `;prev` lines are comments and inih ignores them.
This lands in `updateIniSectionKeys` + `saveMotorGeometry`, and `saveCalibration()` is the only caller.
## 10. Declaring presence
The existing code degrades gracefully everywhere — `TestRunner` skips the pitch and IMU leaves, CamEvent
falls back to encoder pitch (or the fixed `pitch_angle_at_center` with no pitch axis), `HeadingFit` is
IMU-free by design, `CaptureScheduler` and `gimbal move` send the single-axis `MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a
failure is indistinguishable from a configuration**: a 2-axis tower whose pitch stops enumerating becomes
a yaw-only tower, captures one elevation band, and reports itself healthy.
| Declared | Found | Result |
|---|---|---|
| present | present | OK |
| **present** | **absent** | **refuse to start**, naming the subsystem; `--degraded` overrides, loudly |
| absent | present | warn — the file is stale |
## 11. `test hardware`
| Leaf | Checks |
|---|---|
| `test hardware axes` | every axis in `axes` appears in telemetry, and no others |
| `test hardware motor` | `DUMP` reports the drivetrain values `hardware.ini` specifies — catches a stale flash, an unanswered `NEED`, or a tower left on limp-home defaults |
| `test hardware imu` | declared → device opens, `0xFA`-framed stream arrives |
| `test hardware env` | declared → a **real I2C** adapter (`i2cdetect -F` says `I2C = yes`, not SMBus-only) and a CRC-valid SHT41 measurement — known-issues trap #2, documented but never automated |
| `test hardware camera` | every camera in `cameras` is found; `usbfs_memory_mb ≥ 1000`; USB3 LPM off on the `1ab2` port |
## 12. What stops being declared twice
| Today | After |
|---|---|
| ~~Production speeds hardcoded in `Application.cpp` contradict `firmware/src/config.h` — and win~~ **fixed** | `[Motor.<axis>] scan_speed_deg_s` in `hardware.ini`, in degrees per second, converted to VMAX per tower |
| Firmware `MOTOR_CONFIG` duplicates every drivetrain value the host also knows | firmware holds none of them until told |
| Firmware `NUM_AXES` hardcoded; host infers axes from telemetry | generated from `[Hardware] axes` |
| ~~`[Features] enable_imu` / `enable_env`~~ **fixed** | derived from `[Hardware] imu` / `env`; the flags remain only for towers without a `hardware.ini`, and a flag that contradicts the declaration is warned about and ignored |
`mock_camera` / `mock_serial` / `mock_imu` / `mock_env` stay in `config.ini` — dev-box substitution is
operational, and it is what lets all of this be exercised with no hardware.
## 13. Migration
With no `hardware.ini`, synthesise one in memory from the current `config.ini` plus today's firmware
defaults, and log the file it *would* have written. Ship `install.sh --extract` to split an existing
device config. Reconciliation starts **warn-only**.
Deploy host-before-firmware (A.6). Fix the `Application.cpp:1040` literals in the same change — on the
existing fleet that drops yaw 50000 → 30000, a real behaviour change that wants a deliberate rig check
rather than a silent correction.
> ### Tower 111 is migrated (2026-09-23 host, 2026-09-24 firmware)
>
> Through `scripts/install-tower` with the Delta kit. 111 has a `hardware.ini`
> (yaw + pitch, MTi on `/dev/ttyS0`, SHT41 on `/dev/i2c-2`), its USB
> preconditions, the current host, and firmware `a0b1e4a`. `test hardware`
> passes every leaf, `hw_motor` included, and `install-tower --verify` is green.
> A rollback copy of the previous sources, binary, firmware source and
> `config.ini` is in `~ggs/rollback-20260923/` on the tower.
>
> The drivetrain was not invented, and not measured afresh either: it is what
> the firmware it replaced (`af74190-dirty`) was running, converted to
> `hardware.ini`'s units with the sense resistor read off the boards (75 mOhm,
> both axes). Round-tripped through the host's own planners back to identical
> registers: GLOBALSCALER 80 / 50, IRUN 31, homing at the same (the Delta never
> had a reduced search current), `steps_per_rev` 177000 / 500000 (the guard
> unchanged; measured spans are 182,374 / 572,004), SW_MODE 0x8A0 / 0x8A3,
> ENC_DEVIATION 1500, homing and centring speeds equal to the old VMAX figures.
> `travel_deg` 361.7 / 40.5 is the homed span over the landmark-calibrated
> `counts_per_deg`, and the host confirmed it agrees with the calibration after
> the first home on the new firmware. Homing repeated the old firmware's travel
> to within 0.03 %.
>
> Two corrections to what this section used to say:
>
> - `test hardware` does **not** pass on the old firmware. An absent echo is
> reported as unconfirmable, not as a mismatch - but unconfirmable is still a
> FAIL, by design: a check that could not happen must not read as a pass. So on
> a Delta the gate before flashing is "everything but `hw_motor` green, and
> `hw_motor` failing only on missing echoes".
> - Speed: `scan_speed_deg_s = 8.0` applies to **both** axes. On 111 that is VMAX
> 5638 for yaw but 157861 for pitch, above the 150000 it ran at; pitch logs an
> occasional encoder tracking error just over the 400-count limit. Accepted for
> now (2026-09-24); `[Motor.pitch] scan_speed_deg_s` is the knob.
## 14. Firmware changes, collected
- **Per board** → `platform_leonardo.h` / `platform_iota.h`: pin map plus the three AVR-isms
(`MCUSR`, `__heap_start`/`__brkval`) behind `platformResetCause()` / `platformFreeRam()`.
- **Generated** → pins and `NUM_AXES` from `hardware.ini`.
- **Deleted** → the drivetrain fields of `MOTOR_CONFIG`; `config.h` keeps only mechanism-wide thresholds.
- **New** → `NEEDCFG` state, `NEED` request line, generic `SET <axis> <key> <value>`, `USE DEFAULTS`,
split init block, drivetrain values reported in `DUMP`. The existing `GIT_REV` in `BOOT`/`DUMP` is
unchanged — no version numbers are added (§7).
---
# Sequencing
| Phase | Work | Gate |
|---|---|---|
| **1** | **Host**: split `hardware.ini` out of `config.ini` (§3), resolution chain (§4), presence reconciliation (§10), `test hardware` (§11), calibration `;prev` comments (§9), build identity (§7), `install.sh --extract` (§8). **Delete the `Application.cpp:1040` speed literals** (§5). Roll out across the live fleet, warn-only | `test hardware` green on every tower; header shows a real build id; an unplugged pitch axis *fails* instead of degrading silently |
| **2** | **Firmware**: `NEEDCFG` + `NEED` / `SET` / `USE DEFAULTS`, split init block, drivetrain values out of `MOTOR_CONFIG` and into `DUMP` (§6). **Host first, then firmware** | A firmware with no host sits in `NEEDCFG` and moves nothing; with the host it reaches `READY` holding exactly the `hardware.ini` values; an old firmware that never sends `NEED` is detected as such and falls back |
Phase 1 is deployable on its own. Phase 2 requires it.
## Progress
**Complete.** Merged to `main`; the branch `worktree-hardware-config` is kept only
as a marker and has nothing `main` lacks. 327 tests pass, up from a 204 baseline.
Every row below is delivered, and §6 is validated on tower 121 — see the note
after the table.
| § | Item | State |
|----|------|-------|
| 7 | Build identity — CMake reads `$ENV{GIT_REV}`; `Application.cpp` stops hardcoding `"dev"` | **done**, verified embedded in the binary |
| 9 | Calibration `;prev` comments, one generation, changed keys only, stamped with the command | **done**, 7 tests |
| 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.<axis>]` 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.<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. Later extended: the host also re-sends the whole declared block unprompted on every connect, because the firmware asks only once, at boot |
| 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: builds against a generated Delta `hardware.ini`; the pin echo costs **84 B of flash** (22,876 -> 22,960 B, 79.8% -> 80.1%, 5,712 B free) and no RAM. It first cost 170 B, because the SPI echo was gated on `PIN_SPI_SCK`, which the AVR core also defines (for the fixed ICSP pins); it is gated on the RP2040 now. Flashed to tower 111 on 2026-09-24 (the 170 B build, which also reports `spi=15,16,14` - harmless, and gone at its next flash). 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:
the firmware emitted `NEED`, the host answered, and `DUMP` echoed every value back
with a full `cfg_rx` mask. The refusal path was exercised too — `HOME` returned
`ERR need config`, `RESET` did not escape `NEEDCFG`, and only `USE DEFAULTS`
released it. Homing repeatability then held at **0.26 % across nine runs**,
spanning two firmware generations, a centre change, a doubled search speed and a
tightened stall threshold.
One gap only showed up on the rig: the firmware asks **once**, at boot, and only
while in `NEEDCFG`, so a configured board says nothing and there was nothing for
the host to answer. Editing `hardware.ini` and restarting the host silently
changed nothing for every key that had no command of its own. The host now
re-sends the whole declared block on connect — confirmed by moving
`enc_deviation` 1000 -> 1200 -> 1000 with host restarts alone, the board never
rebooting.
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.
> **§5 is the exception, and it is deliberate.** `scan_speed_deg_s` defaults to **8.0 deg/s** and
> applies whether or not a `hardware.ini` exists, because the literals it replaced were themselves
> unconditional. **Every tower changes speed on upgrade**: the Delta fleet goes from 36.4 deg/s to
> 8.0, so a full revolution takes ~45 s instead of ~16.5 s. Check that against `image_interval` and
> the scan grid's waypoint count before deploying, and set `scan_speed_deg_s` per tower if 8.0 is
> not right for it. An axis whose `counts_per_deg` is untrustworthy is left on the firmware default
> instead.
---
# Decisions needed before work starts
1. **How do the files find each other?** Recommendation: a fixed `/etc/fire_gimbal_control/hardware.ini`,
matching the existing `credentials.env` convention, rather than a pointer in `config.ini` (§4).
2. ~~**The `SPEED Y 50000` / `SPEED P 150000` literals contradict the firmware's tuned-down defaults and
win.** Is the intended yaw production speed 30000 or 50000?~~ **ANSWERED, and the question turned out to
be mis-framed.** 30000 was intended *for tower 111* — but a VMAX is not a portable quantity: the same
30000 is 21.8 deg/s on the Delta and 6.0 deg/s on the IOTA. Speed is now declared in **degrees per
second** (`[Motor.<axis>] scan_speed_deg_s`, default 8.0) and converted per tower through
`counts_per_deg`. The literals were 36.4 deg/s.
3. **`NEEDCFG` — refuse to move, or fall back after a timeout?** Recommendation: refuse, with an explicit
`USE DEFAULTS` for bench work. A timeout lets a slow host produce a silently mis-tuned tower (§6).
4. **How strict is reconciliation, and when?** Recommendation: warn-only through the fleet rollout, then
fatal, with `--degraded` as the field-triage escape (§10).
5. **Do any live rigs differ in axis count**, or is the variation only IMU/env? If every tower is 2-axis
today, `axes` guards against future drift rather than fixing something current, and phase 1 shrinks.