fwt_software/docs/hardware-config-plan.md

460 lines
23 KiB
Markdown
Raw 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` 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=<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, `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:1040` contradict `firmware/src/config.h`** — and win | one value in `hardware.ini`, requested by the firmware |
| 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` | derived from `imu` / `env`; the flags retire |
`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 <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
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
216 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** (the `hardware/<board>/` setup kit is not started) |
| 10 | Presence reconciliation (declared vs found) | **not started** — `hasAxis()` is in place and returns true for an undeclared rig, so callers can adopt it before reconciliation exists |
| 11 | `test hardware` | **not started** |
| 5 | Delete the `Application.cpp:1040` speed literals | **not started** — blocked on decision 2 |
Nothing yet changes behaviour on a tower without a `hardware.ini`: absent is silent, and
`hardware.declared` stays false. Verified by running the binary both ways.
---
# 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 in `Application.cpp:1040` contradict the firmware's
tuned-down defaults and win.** Is the intended yaw production speed 30000 or 50000? Live on the fleet
now, and worth answering before the refactor (§5).
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.