Compare commits

..

No commits in common. "12e23a5fb648818db95a4400444758f2b2e2695d" and "00c998301ad826715ee4845b9eb9e6a05ee5d93e" have entirely different histories.

5 changed files with 67 additions and 179 deletions

View File

@ -731,10 +731,10 @@ full torque — the fire it is there to prevent.
### The config handshake — the firmware asks, `hardware.ini` answers ### The config handshake — the firmware asks, `hardware.ini` answers
The firmware used to carry a full parameter table and **guess** when the host Today the firmware carries a full parameter table and **guesses** when the host
said nothing, so every drivetrain value existed in two places and the two could says nothing, so every drivetrain value exists in two places and the two can
disagree. It no longer guesses: it declares what it needs and refuses to move disagree. Section 6 of the config plan replaces that: the firmware declares what
until it has it. it needs and refuses to move until it has it.
``` ```
firmware -> BOOT build=abc123 firmware -> BOOT build=abc123
@ -767,23 +767,11 @@ Three rules, each protecting against a different silent failure:
> `ST` line — deliberately not red, and deliberately not "no telemetry": the link > `ST` line — deliberately not red, and deliberately not "no telemetry": the link
> is fine and the axis is talking, it just has not been told what it is driving. > is fine and the axis is talking, it just has not been told what it is driving.
> >
> **Retuning needs only a restart.** The firmware asks exactly once, at boot, > **Status: the host half only.** The host answers `NEED` and the responder is
> and only while it is in `NEEDCFG` — so the handshake alone would mean that > verified end to end, but the firmware that *asks* is not written yet. Set
> editing `hardware.ini` and restarting the *host* changed nothing, because the > `[Features] mock_needcfg = 1` to make the simulated controller run the
> board is already configured and says nothing to reply to. The host therefore > handshake — emit `NEED` at start, reject `HOME` / `MOVE` / `ENABLE` until every
> re-sends the whole declared block on every connect, whether or not it was > requested key is answered — which is how this is exercised on a dev box.
> asked. Edit, restart, done; no re-flash.
>
> It also makes the handshake robust to a `NEED` line lost on the wire: an axis
> still sitting in `NEEDCFG` is completed by that re-send rather than refusing to
> move until someone notices.
>
> **Both halves are live**, and validated on tower 121: the firmware emits `NEED`
> at boot and refuses `HOME` / `MOVE` / `ENABLE` until every required key is
> answered, and the host answers it. On a dev box with no board attached, set
> `[Features] mock_needcfg = 1` to make the simulated controller run the same
> handshake, or `0` to have it stand in for a board that is already configured —
> which is what exercises the unprompted re-send.
### Why speed is declared in degrees per second ### Why speed is declared in degrees per second

View File

@ -276,24 +276,6 @@ If `CMakeLists.txt` or options changed, re-run the `cmake -B build ...` configur
[section 2](#2-build-full-with-cameras--mqtt) first. To roll back, keep the previous binary [section 2](#2-build-full-with-cameras--mqtt) first. To roll back, keep the previous binary
(`fire_gimbal_control.prev`) and swap it back. (`fire_gimbal_control.prev`) and swap it back.
> **One-time: `git pull` can delete your `.deploy.env`.** It was tracked by
> mistake until `b0127db`, which stopped tracking it and added it to
> `.gitignore`. Pulling *across* that commit therefore does what git does to any
> file a commit removes — deletes the working copy, credentials and all. It is a
> checkout, not a merge conflict, so there is no warning and nothing to resolve.
>
> Only checkouts that predate `b0127db` are exposed, and only once. Copy it aside
> first:
>
> ```bash
> cp .deploy.env ../deploy.env.bak 2>/dev/null # then pull, then copy back
> ```
>
> After that pull the file is ignored and stays put. `deploy.sh` already excludes
> it from the rsync, so deploying never overwrites the tower's own copy — this is
> purely about the machine you deploy *from*. To recover one already lost:
> `git show b0127db^:.deploy.env`.
## 10. Where the log is ## 10. Where the log is
The program writes a persistent log alongside the self-test and diagnostics The program writes a persistent log alongside the self-test and diagnostics

View File

@ -425,10 +425,8 @@ Phase 1 is deployable on its own. Phase 2 requires it.
## Progress ## Progress
**Complete.** Merged to `main`; the branch `worktree-hardware-config` is kept only Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
as a marker and has nothing `main` lacks. 327 tests pass, up from a 204 baseline. 298 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 | | § | Item | State |
|----|------|-------| |----|------|-------|
@ -441,28 +439,11 @@ after the table.
| 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) | | 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 | | 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 | | 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 — **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 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 | 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` | | 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 |
| 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 | | 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 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. silent and `hardware.declared` stays false. Verified by running the binary both ways.

View File

@ -301,9 +301,9 @@ commit `aab1925`.
| 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** | | 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** |
| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built.** Latest, 2026-09-18, with the config handshake: **iota_rp2040** 51716 B flash (0.6 % of 8 MB), 9108 B RAM (3.5 %); **leonardo** 22646 B flash (**79 % of 28 KB**), 939 B RAM (36.7 %). Both from `./deploy.sh --compile-only` against towers 121 and 111; 33 native tests pass (`pio test -e native`) | | 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built.** Latest, 2026-09-18, with the config handshake: **iota_rp2040** 51716 B flash (0.6 % of 8 MB), 9108 B RAM (3.5 %); **leonardo** 22646 B flash (**79 % of 28 KB**), 939 B RAM (36.7 %). Both from `./deploy.sh --compile-only` against towers 121 and 111; 33 native tests pass (`pio test -e native`) |
| 2 | Per-board pin map + `NUM_AXES` in `platform_leonardo.h` / `platform_iota.h`, as-built wiring | **done** — compile-time, not yet generated from `hardware.ini` | | 2 | Per-board pin map + `NUM_AXES` in `platform_leonardo.h` / `platform_iota.h`, as-built wiring | **done** — compile-time, not yet generated from `hardware.ini` |
| 2 | Pins + `NUM_AXES` generated from `hardware.ini` | **not started, no longer blocked** — the config plan it waited on is complete. The last open phase-2 item | | 2 | Pins + `NUM_AXES` generated from `hardware.ini` | **not started** — needs the config plan's phase 2 first |
| 3 | Yaw homing on the real drivetrain | **done** — the endstop was being sheared through at full torque; homing at 189 mA holds. See below | | 3 | Yaw homing on the real drivetrain | **done** — the endstop was being sheared through at full torque; homing at 189 mA holds. See below |
| 1 | Phase 1 in full — `hardware.ini`, the config handshake, presence reconciliation, `test hardware` | **done.** [hardware-config-plan.md](hardware-config-plan.md) is complete and validated on tower 121; the gate ("`test hardware` green on every existing tower") is met on 121, and still wants running against the Delta fleet | | 1 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) |
> **Where the firmware gets built.** PlatformIO is not installed on the dev box; the firmware is built > **Where the firmware gets built.** PlatformIO is not installed on the dev box; the firmware is built
> *remotely on a tower* by `firmware/deploy.sh --compile-only`. Locally, the Arduino-free sources > *remotely on a tower* by `firmware/deploy.sh --compile-only`. Locally, the Arduino-free sources
@ -313,18 +313,11 @@ commit `aab1925`.
> That catches typos and signatures; it says nothing about size or behaviour, so a remote build is > That catches typos and signatures; it says nothing about size or behaviour, so a remote build is
> still the gate. > still the gate.
> >
> **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79.6 % of > **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79 % of
> its 28 KB** (5838 B left), up from 19254 B at the phase 2 gate — that growth is everything since: > its 28 KB** (6026 B left), up from 19254 B at the phase 2 gate — that growth is everything since:
> `BOOTSEL`, `CURRENT`, the reduced homing current, `STOP` ending a homing, the config handshake and > `BOOTSEL`, `CURRENT`, the reduced homing current, `STOP` ending a homing, and the config handshake.
> §14. RP2040 flash is a non-issue at 0.6 % of 8 MB. > RP2040 flash is a non-issue at 0.6 % of 8 MB. Deleting the drivetrain fields from `MOTOR_CONFIG`
> > (config plan §14) now buys space as well as removing a duplicate definition.
> **§14 does not relieve it — measured, not assumed.** Deleting the drivetrain fields from
> `MOTOR_CONFIG` *cost* 222 B of flash on the AVR (22646 → 22868) while saving 68 B of RAM
> (939 → 871). The table it removed was only ~35 B per axis, and the limp-home constants, the
> `SW_MODE` derivation, the new accessors and the limp-home flag and warning more than spend
> that. An earlier version of this note claimed §14 "buys space"; it does not. It was worth
> doing for the duplicate definition it removes, not for the headroom — so the flash constraint
> still wants a real answer if the Delta fleet is to gain features.
> >
> To actually build it: `cd firmware && ./deploy.sh --compile-only` compiles on the device without > To actually build it: `cd firmware && ./deploy.sh --compile-only` compiles on the device without
> flashing. That rsyncs over the tower's firmware source tree, so it is a deliberate step, not one to > flashing. That rsyncs over the tower's firmware source tree, so it is a deliberate step, not one to
@ -368,12 +361,8 @@ The fix is to make the search weak enough that the screw wins:
Declared per tower in `hardware.ini` as `[Motor.yaw] homing_current_ma`, converted Declared per tower in `hardware.ini` as `[Motor.yaw] homing_current_ma`, converted
to registers by the host and sent as `CURRENT` before every home — so retuning it to registers by the host and sent as `CURRENT` before every home — so retuning it
is a restart, not a reflash. **That is now true of the whole drivetrain block**: is a restart, not a reflash. See [configuration.md](configuration.md) for the keys
the host re-sends every declared value on connect, not only the ones with their and `firmware/docs/hardware-notes.md` for the mechanism.
own command. It was briefly true of the current alone, which made half a
`hardware.ini` edit take effect on a restart and half silently not. See
[configuration.md](configuration.md) for the keys and
`firmware/docs/hardware-notes.md` for the mechanism.
Three things this exposed, all since fixed: Three things this exposed, all since fixed:

View File

@ -1243,51 +1243,7 @@ struct Application::Impl {
LOG_WARN << "unparseable NEED line from the firmware: " << line; LOG_WARN << "unparseable NEED line from the firmware: " << line;
continue; continue;
} }
answerNeed(*need, /*asked=*/true); const bool is_yaw = need->axis == 'Y';
}
}
// Every key the host can answer, by its canonical name. Used to re-send the
// whole declared set on connect - see resendDeclaredConfig().
static NeedRequest fullRequest(char axis) {
NeedRequest r;
r.axis = axis;
r.keys = {"steps_per_rev", "global_scaler", "run_irun", "homing_irun",
"center_count", "soft_margin", "enc_deviation", "normal_speed",
"homing_speed", "center_speed", "sw_mode", "has_encoder"};
return r;
}
// Push the declared drivetrain at the firmware whether or not it asked.
//
// The firmware asks exactly once, at boot, and only while it is in NEEDCFG.
// Without this, editing hardware.ini and restarting the HOST changes nothing
// - the board is already configured, so it says nothing, and the host has
// nothing to reply to. The tower keeps running the old values silently.
// Re-tuning would need a re-flash, which is seven seconds on a bench and a
// trip up a mast in the field.
//
// CENTER and MARGIN are sent again as SET here, having just gone out as
// their own commands. That is idempotent and costs two lines; keeping the
// set uniform is worth more than saving them.
//
// It also makes the handshake robust to a NEED line lost on the wire: an
// axis still in NEEDCFG is completed by this re-send rather than sitting
// there refusing to move.
void resendDeclaredConfig() {
for (const char* name : {"yaw", "pitch"}) {
if (!cfg.hardware.hasAxis(name)) continue;
answerNeed(fullRequest(name[0] == 'y' ? 'Y' : 'P'), /*asked=*/false);
}
}
// `asked` distinguishes the two callers. When the firmware ASKED, a key the
// host cannot supply is a startup failure - the axis stays in NEEDCFG and
// will not move, so it is an error. On a proactive re-send it means only
// "hardware.ini does not declare this one", and the firmware simply keeps
// what it already had; saying ERROR there would cry wolf on every start.
void answerNeed(const NeedRequest& need, bool asked) {
const bool is_yaw = need.axis == 'Y';
// An axis the tower says it does not have. That is not a missing // An axis the tower says it does not have. That is not a missing
// declaration - it is a firmware built for more axes than this rig, // declaration - it is a firmware built for more axes than this rig,
@ -1296,13 +1252,13 @@ struct Application::Impl {
// that way rather than as a hole in hardware.ini. // that way rather than as a hole in hardware.ini.
const char* axis_name = is_yaw ? "yaw" : "pitch"; const char* axis_name = is_yaw ? "yaw" : "pitch";
if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) { if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) {
LOG_ERROR << "firmware asked for axis " << need.axis << " (" << axis_name LOG_ERROR << "firmware asked for axis " << need->axis << " (" << axis_name
<< "), which this tower does not declare (axes " << "), which this tower does not declare (axes "
<< cfg.hardware.axesLabel() << cfg.hardware.axesLabel()
<< ") -- the firmware was built for more axes than the rig has; " << ") -- the firmware was built for more axes than the rig has; "
"rebuild it for this board rather than declaring an axis that is " "rebuild it for this board rather than declaring an axis that is "
"not fitted"; "not fitted";
return; continue;
} }
const auto src = is_yaw const auto src = is_yaw
? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw, ? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw,
@ -1311,30 +1267,28 @@ struct Application::Impl {
cfg.drivetrain.pitch, pitch_current_, cfg.drivetrain.pitch, pitch_current_,
cfg.pitch_soft_margin_counts); cfg.pitch_soft_margin_counts);
const auto plan = planFirmwareConfig(need, src); const auto plan = planFirmwareConfig(*need, src);
for (const auto& cmd : plan.sets) motor->sendCommand(cmd); for (const auto& cmd : plan.sets) motor->sendCommand(cmd);
if (!plan.unanswered.empty() && asked) { if (!plan.unanswered.empty()) {
std::string keys; std::string keys;
for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k; for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k;
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
<< " and hardware.ini does not declare " << " and hardware.ini does not declare "
<< (plan.unanswered.size() == 1 ? "it" : "them") << (plan.unanswered.size() == 1 ? "it" : "them")
<< "; the axis stays in NEEDCFG and will not move"; << "; the axis stays in NEEDCFG and will not move";
} }
if (!plan.unknown.empty() && asked) { if (!plan.unknown.empty()) {
std::string keys; std::string keys;
for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k; for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k;
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
<< ", which this host does not know -- the firmware is newer than the " << ", which this host does not know -- the firmware is newer than the "
"host; upgrade the host rather than editing hardware.ini"; "host; upgrade the host rather than editing hardware.ini";
} }
if (asked && plan.complete()) if (plan.complete())
LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for axis " LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for axis "
<< need.axis; << need->axis;
if (!asked && !plan.sets.empty()) }
LOG_INFO << "drivetrain re-sent to axis " << need.axis << ": " << plan.sets.size()
<< " values from hardware.ini";
} }
// Convert this tower's declared milliamps into TMC register fields, once. // Convert this tower's declared milliamps into TMC register fields, once.
@ -1435,12 +1389,6 @@ struct Application::Impl {
}; };
sendCurrent('Y', yaw_current_); sendCurrent('Y', yaw_current_);
sendCurrent('P', pitch_current_); sendCurrent('P', pitch_current_);
// And the rest of the declared drivetrain. CENTER/MARGIN/CURRENT above
// have always been re-sent on every start; the keys the config handshake
// added were not, so half the tuning took effect on a restart and half
// silently did not. This makes all of it behave the same way.
resendDeclaredConfig();
} }
// Derive each axis's reachable degree window from the freshly homed soft // Derive each axis's reachable degree window from the freshly homed soft