26 KiB
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.
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:
- Building a new tower is writing one file.
- It survives deploys structurally.
deploy.sh:234keepsconfig.iniout of rsync by an exclude rule someone has to maintain;deploy.sh:227records that--deletealready destroyed a freshly exported camera XML once. A file outside the deploy tree cannot be lost that way. - 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
[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 150000Hardcoded in host source. Meanwhile
firmware/src/config.hsetsnormal_speedto 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_deviationis the one that bears on damage. Yaw homing detects the endstop when|XACTUAL − XENC|exceeds it.firmware/docs/hardware-notes.mdrecords 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_revis whatHOMING_MIN_TRAVEL_SLACKvalidates each home against.yaw_counts_per_degmust be recalibrated. The 983.33 default is derived fromsteps_per_rev = 177000; on a different ratio it is wrong, sogimbal heading setupis 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:70reads "host-owned homing parameters (config.ini, sent as CENTER / MARGIN)". - The host already verifies the firmware adopted them.
Application.cpp:1099errors with "the firmware did not adopt CENTER (is it older than this host?) … Refusing to derive travel limits; headings would be wrong", and:1108warns "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
- Boot: pins and SPI up, then stop the motors. Unconditional, no config needed — see the safety note below.
- State
NEEDCFG. Motors stay disabled;HOME/MOVE/ENABLEare rejected withERR need config. Reported inSTandDUMP, so the dashboard shows why nothing moves. - On host connection, ask.
main.cppalready 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 ... - Host answers from
hardware.iniwith a genericSET <axis> <key> <value>.SPEED,CENTERandMARGINstay as aliases so old hosts and hand-typed sessions keep working. - When an axis's required set is complete, the firmware applies the parameterised init block and
moves that axis to
ST_RESET.DUMPreports every value it is holding, so the existing adoption check generalises from "did CENTER land?" to "does the firmware hold whathardware.inisays?" — which istest hardware motorin A.9.
⚠️ The safety constraint this must not break
applyInitBlock ends with, and hardware-notes.md explains why:
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.iniinjectsGIT_REVat build time, "so the flashed firmware carries a build identity", fromgit describe --always --dirty.- The firmware reports it in
BOOT build=<rev>(main.cpp:39) andDUMP BEGIN build=<rev>(:103). - The host parses it into
DumpData.build(DumpParser.h:46) and already renders it in the gimbal pane —TuiUi.cpp:999drawsbuild <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:
HeaderViewhas abuildfield (UiSnapshot.h:185), andTuiUi.cpp:1324already renderstext("build " + s.header.build) | dimin the main header, right after the tower name — exactly where you suggested putting it.deploy.sh:212computesGIT_REV="$(git describe --always --dirty)"and:288passes it into the remote CMake invocation.- But
CMakeLists.txtnever reads$ENV{GIT_REV}, so the value is passed and dropped, andApplication.cpp:610hardcodess.header.build = "dev".
Two edits close it:
CMakeLists.txtreads$ENV{GIT_REV}into a compile definition, defaulting to"unknown"— exactly asfirmware/src/main.cpp:14already does.Application.cpp:610uses 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 --dirtycurrently yields a bare hash (1a572cb-dirty) — an honest build identity, but not a version. Tagging releases would make the same line readv1.4.0-2-g1a572cbat no extra cost, if you ever want it. And$ENV{GIT_REV}is read at CMake configure time, so a plaincmake --buildafter edits keeps the configure-time value;deploy.sh:288reconfigures 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 toDUMPand confirms it is actually holding thehardware.inivalues — what the firmware did, not what it claimed.Application.cpp:1101and:1108already 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:944raises the activity-strip prompt "Save the new [Motor] calibration to config as the default? (y / n)" whenevercalib_save_pending_is set.Application.cpp:1260/:1726set it aftergimbal heading setup/heading fit/pitch setup.Application.cpp:1847implementssaveanddiscardas 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:
[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 |
|---|---|
Application.cpp contradict firmware/src/config.h — and win |
[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 |
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) behindplatformResetCause()/platformFreeRam(). - Generated → pins and
NUM_AXESfromhardware.ini. - Deleted → the drivetrain fields of
MOTOR_CONFIG;config.hkeeps only mechanism-wide thresholds. - New →
NEEDCFGstate,NEEDrequest line, genericSET <axis> <key> <value>,USE DEFAULTS, split init block, drivetrain values reported inDUMP. The existingGIT_REVinBOOT/DUMPis 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) |
| 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. The firmware half is not written |
| 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 <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. 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
silent and hardware.declared stays false. Verified by running the binary both ways.
§5 is the exception, and it is deliberate.
scan_speed_deg_sdefaults to 8.0 deg/s and applies whether or not ahardware.iniexists, 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 againstimage_intervaland the scan grid's waypoint count before deploying, and setscan_speed_deg_sper tower if 8.0 is not right for it. An axis whosecounts_per_degis untrustworthy is left on the firmware default instead.
Decisions needed before work starts
- How do the files find each other? Recommendation: a fixed
/etc/fire_gimbal_control/hardware.ini, matching the existingcredentials.envconvention, rather than a pointer inconfig.ini(§4). TheANSWERED, 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 (SPEED Y 50000/SPEED P 150000literals contradict the firmware's tuned-down defaults and win. Is the intended yaw production speed 30000 or 50000?[Motor.<axis>] scan_speed_deg_s, default 8.0) and converted per tower throughcounts_per_deg. The literals were 36.4 deg/s.NEEDCFG— refuse to move, or fall back after a timeout? Recommendation: refuse, with an explicitUSE DEFAULTSfor bench work. A timeout lets a slow host produce a silently mis-tuned tower (§6).- How strict is reconciliation, and when? Recommendation: warn-only through the fleet rollout, then
fatal, with
--degradedas the field-triage escape (§10). - Do any live rigs differ in axis count, or is the variation only IMU/env? If every tower is 2-axis
today,
axesguards against future drift rather than fixing something current, and phase 1 shrinks.