# 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 /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 # explicit, for dev and testing $FGC_HARDWARE # service environment /etc/fire_gimbal_control/hardware.ini # the normal answer on a tower /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 `. `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` 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. ### 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=` (`main.cpp:39`) and `DUMP BEGIN build=` (`:103`). - The host parses it into `DumpData.build` (`DumpParser.h:46`) and **already renders it in the gimbal pane** — `TuiUi.cpp:999` draws `build up reset:` 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, `HeadingFit` is IMU-free by design, `CaptureScheduler` sends 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.] 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. ## 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 `, `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 Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`). 295 tests pass, up from a 204 baseline. | § | 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) | | 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 | 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 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.] 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.