Compare commits
2 Commits
00c998301a
...
12e23a5fb6
| Author | SHA1 | Date |
|---|---|---|
|
|
12e23a5fb6 | |
|
|
bd3a568508 |
|
|
@ -731,10 +731,10 @@ full torque — the fire it is there to prevent.
|
|||
|
||||
### The config handshake — the firmware asks, `hardware.ini` answers
|
||||
|
||||
Today the firmware carries a full parameter table and **guesses** when the host
|
||||
says nothing, so every drivetrain value exists in two places and the two can
|
||||
disagree. Section 6 of the config plan replaces that: the firmware declares what
|
||||
it needs and refuses to move until it has it.
|
||||
The firmware used to carry a full parameter table and **guess** when the host
|
||||
said nothing, so every drivetrain value existed in two places and the two could
|
||||
disagree. It no longer guesses: it declares what it needs and refuses to move
|
||||
until it has it.
|
||||
|
||||
```
|
||||
firmware -> BOOT build=abc123
|
||||
|
|
@ -767,11 +767,23 @@ Three rules, each protecting against a different silent failure:
|
|||
> `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.
|
||||
>
|
||||
> **Status: the host half only.** The host answers `NEED` and the responder is
|
||||
> verified end to end, but the firmware that *asks* is not written yet. Set
|
||||
> `[Features] mock_needcfg = 1` to make the simulated controller run the
|
||||
> handshake — emit `NEED` at start, reject `HOME` / `MOVE` / `ENABLE` until every
|
||||
> requested key is answered — which is how this is exercised on a dev box.
|
||||
> **Retuning needs only a restart.** The firmware asks exactly once, at boot,
|
||||
> and only while it is in `NEEDCFG` — so the handshake alone would mean that
|
||||
> editing `hardware.ini` and restarting the *host* changed nothing, because the
|
||||
> board is already configured and says nothing to reply to. The host therefore
|
||||
> re-sends the whole declared block on every connect, whether or not it was
|
||||
> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -276,6 +276,24 @@ 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
|
||||
(`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
|
||||
|
||||
The program writes a persistent log alongside the self-test and diagnostics
|
||||
|
|
|
|||
|
|
@ -425,8 +425,10 @@ Phase 1 is deployable on its own. Phase 2 requires it.
|
|||
|
||||
## Progress
|
||||
|
||||
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
|
||||
298 tests pass, up from a 204 baseline.
|
||||
**Complete.** Merged to `main`; the branch `worktree-hardware-config` is kept only
|
||||
as a marker and has nothing `main` lacks. 327 tests pass, up from a 204 baseline.
|
||||
Every row below is delivered, and §6 is validated on tower 121 — see the note
|
||||
after the table.
|
||||
|
||||
| § | Item | State |
|
||||
|----|------|-------|
|
||||
|
|
@ -439,11 +441,28 @@ Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-confi
|
|||
| 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 | Config handshake — **host half** | **done**, 13 tests. `FirmwareConfig.h` parses `NEED` and plans the `SET` lines; unanswered keys and unknown keys are reported separately. `[Features] mock_needcfg` makes the simulated controller run the handshake, so the host half is verified with no firmware. Later extended: the host also re-sends the whole declared block unprompted on every connect, because the firmware asks only once, at boot |
|
||||
| 6 | Config handshake — **firmware half** | **done, and validated on tower 121.** `NEEDCFG` state (`ST` code `C`), `NEED` emitted with the banner, `SET <axis> <key> <value>`, `USE DEFAULTS`, and the init block split so the residual-motion stop still runs at boot. 33 native tests pass under `pio test -e native`. On the rig: the handshake was watched live, `HOME` refused with `ERR need config`, `RESET` did not escape the state, and `USE DEFAULTS` released it |
|
||||
| 6 | Adoption check generalised | **done**, 9 tests. `DUMP` echoes every host-owned value plus a `cfg_rx` bitmask; `test hardware motor` checks all of them, and reports an absent field as unconfirmable rather than as a mismatch against -1. Round trip NEED -> SET -> DUMP -> PASS verified against `mock_needcfg` |
|
||||
| 14 | Drivetrain out of `MOTOR_CONFIG` | **done.** The struct keeps pins and `homing_kind` only; `platform_iota.h` keeps its measurement record but no longer defines this tower's values. `USE DEFAULTS` now loads a shared **limp-home** set (`LIMP_*` in `config.h`) that is deliberately nobody's real values — slow speeds, generous margins, the gentle search current — so a tower still running them fails `test hardware motor` instead of looking configured. `SW_MODE` is derived from `homing_kind` rather than shared, because disabling the hardware stops on an axis that homes on real switches would remove its last protection. Flash cost, not saved: see the porting plan. Found a real bug on the way — `DIAG` read `normal_speed` / `center_speed` from the compile-time table, so it ran at compiled speeds rather than the tower's declared ones |
|
||||
| 6 | `NEEDCFG` understood host-side | **done**, 5 tests. `AxisState::NeedConfig` parsed from `ST` `C`, named in `DUMP` and the dashboard, and counted as telemetry arriving rather than as a dead link. The firmware enum APPENDS the state (5) rather than inserting it — inserting renumbered every later state and made a READY axis read back as ERROR |
|
||||
|
||||
**Validated on tower 121.** The handshake was watched live on the rig, not inferred:
|
||||
the firmware emitted `NEED`, the host answered, and `DUMP` echoed every value back
|
||||
with a full `cfg_rx` mask. The refusal path was exercised too — `HOME` returned
|
||||
`ERR need config`, `RESET` did not escape `NEEDCFG`, and only `USE DEFAULTS`
|
||||
released it. Homing repeatability then held at **0.26 % across nine runs**,
|
||||
spanning two firmware generations, a centre change, a doubled search speed and a
|
||||
tightened stall threshold.
|
||||
|
||||
One gap only showed up on the rig: the firmware asks **once**, at boot, and only
|
||||
while in `NEEDCFG`, so a configured board says nothing and there was nothing for
|
||||
the host to answer. Editing `hardware.ini` and restarting the host silently
|
||||
changed nothing for every key that had no command of its own. The host now
|
||||
re-sends the whole declared block on connect — confirmed by moving
|
||||
`enc_deviation` 1000 -> 1200 -> 1000 with host restarts alone, the board never
|
||||
rebooting.
|
||||
|
||||
Presence reconciliation (§10) changes nothing on a tower without a `hardware.ini`: absent is
|
||||
silent and `hardware.declared` stays false. Verified by running the binary both ways.
|
||||
|
||||
|
|
|
|||
|
|
@ -301,9 +301,9 @@ commit `aab1925`.
|
|||
| 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 | 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** — needs the config plan's phase 2 first |
|
||||
| 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 |
|
||||
| 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 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) |
|
||||
| 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 |
|
||||
|
||||
> **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
|
||||
|
|
@ -313,11 +313,18 @@ commit `aab1925`.
|
|||
> That catches typos and signatures; it says nothing about size or behaviour, so a remote build is
|
||||
> still the gate.
|
||||
>
|
||||
> **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79 % of
|
||||
> 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, and the config handshake.
|
||||
> 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.
|
||||
> **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79.6 % of
|
||||
> its 28 KB** (5838 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
|
||||
> §14. RP2040 flash is a non-issue at 0.6 % of 8 MB.
|
||||
>
|
||||
> **§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
|
||||
> flashing. That rsyncs over the tower's firmware source tree, so it is a deliberate step, not one to
|
||||
|
|
@ -361,8 +368,12 @@ 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
|
||||
to registers by the host and sent as `CURRENT` before every home — so retuning it
|
||||
is a restart, not a reflash. See [configuration.md](configuration.md) for the keys
|
||||
and `firmware/docs/hardware-notes.md` for the mechanism.
|
||||
is a restart, not a reflash. **That is now true of the whole drivetrain block**:
|
||||
the host re-sends every declared value on connect, not only the ones with their
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1243,54 +1243,100 @@ struct Application::Impl {
|
|||
LOG_WARN << "unparseable NEED line from the firmware: " << line;
|
||||
continue;
|
||||
}
|
||||
const bool is_yaw = need->axis == 'Y';
|
||||
|
||||
// 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,
|
||||
// and answering would configure a driver that is not there. Same
|
||||
// class of fault as presence reconciliation catches, so it is named
|
||||
// that way rather than as a hole in hardware.ini.
|
||||
const char* axis_name = is_yaw ? "yaw" : "pitch";
|
||||
if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) {
|
||||
LOG_ERROR << "firmware asked for axis " << need->axis << " (" << axis_name
|
||||
<< "), which this tower does not declare (axes "
|
||||
<< cfg.hardware.axesLabel()
|
||||
<< ") -- the firmware was built for more axes than the rig has; "
|
||||
"rebuild it for this board rather than declaring an axis that is "
|
||||
"not fitted";
|
||||
continue;
|
||||
}
|
||||
const auto src = is_yaw
|
||||
? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw,
|
||||
yaw_current_, cfg.yaw_soft_margin_counts)
|
||||
: axisConfigSource("pitch", cfg.geometry.pitch,
|
||||
cfg.drivetrain.pitch, pitch_current_,
|
||||
cfg.pitch_soft_margin_counts);
|
||||
|
||||
const auto plan = planFirmwareConfig(*need, src);
|
||||
for (const auto& cmd : plan.sets) motor->sendCommand(cmd);
|
||||
|
||||
if (!plan.unanswered.empty()) {
|
||||
std::string keys;
|
||||
for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k;
|
||||
LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
|
||||
<< " and hardware.ini does not declare "
|
||||
<< (plan.unanswered.size() == 1 ? "it" : "them")
|
||||
<< "; the axis stays in NEEDCFG and will not move";
|
||||
}
|
||||
if (!plan.unknown.empty()) {
|
||||
std::string keys;
|
||||
for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k;
|
||||
LOG_ERROR << "firmware asked axis " << need->axis << " for " << keys
|
||||
<< ", which this host does not know -- the firmware is newer than the "
|
||||
"host; upgrade the host rather than editing hardware.ini";
|
||||
}
|
||||
if (plan.complete())
|
||||
LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for axis "
|
||||
<< need->axis;
|
||||
answerNeed(*need, /*asked=*/true);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// declaration - it is a firmware built for more axes than this rig,
|
||||
// and answering would configure a driver that is not there. Same
|
||||
// class of fault as presence reconciliation catches, so it is named
|
||||
// that way rather than as a hole in hardware.ini.
|
||||
const char* axis_name = is_yaw ? "yaw" : "pitch";
|
||||
if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) {
|
||||
LOG_ERROR << "firmware asked for axis " << need.axis << " (" << axis_name
|
||||
<< "), which this tower does not declare (axes "
|
||||
<< cfg.hardware.axesLabel()
|
||||
<< ") -- the firmware was built for more axes than the rig has; "
|
||||
"rebuild it for this board rather than declaring an axis that is "
|
||||
"not fitted";
|
||||
return;
|
||||
}
|
||||
const auto src = is_yaw
|
||||
? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw,
|
||||
yaw_current_, cfg.yaw_soft_margin_counts)
|
||||
: axisConfigSource("pitch", cfg.geometry.pitch,
|
||||
cfg.drivetrain.pitch, pitch_current_,
|
||||
cfg.pitch_soft_margin_counts);
|
||||
|
||||
const auto plan = planFirmwareConfig(need, src);
|
||||
for (const auto& cmd : plan.sets) motor->sendCommand(cmd);
|
||||
|
||||
if (!plan.unanswered.empty() && asked) {
|
||||
std::string keys;
|
||||
for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k;
|
||||
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys
|
||||
<< " and hardware.ini does not declare "
|
||||
<< (plan.unanswered.size() == 1 ? "it" : "them")
|
||||
<< "; the axis stays in NEEDCFG and will not move";
|
||||
}
|
||||
if (!plan.unknown.empty() && asked) {
|
||||
std::string keys;
|
||||
for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k;
|
||||
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys
|
||||
<< ", which this host does not know -- the firmware is newer than the "
|
||||
"host; upgrade the host rather than editing hardware.ini";
|
||||
}
|
||||
if (asked && plan.complete())
|
||||
LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for 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.
|
||||
// The host owns this because hardware.ini is where the sense resistor lives;
|
||||
// the firmware is told registers and keeps working compile-time defaults.
|
||||
|
|
@ -1389,6 +1435,12 @@ struct Application::Impl {
|
|||
};
|
||||
sendCurrent('Y', yaw_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
|
||||
|
|
|
|||
Loading…
Reference in New Issue