fwt_software/docs/hardware-config-plan.md

30 KiB
Raw Blame History

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:

  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

[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:

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:

[Motor]
;prev yaw_heading_at_center = 0.0        ; 2026-09-11T14:32Z, gimbal heading setup
yaw_heading_at_center = 127.4

Two rules keep it tidy: only record a key whose value actually changed (saveMotorGeometry rewrites all eight every save, so without this the file gains eight lines of noise per calibration), and replace, don't accumulate — an existing ;prev for that key is overwritten, so the file grows once and then holds steady. Reverting is uncommenting; ;prev lines are comments and inih ignores them.

This lands in updateIniSectionKeys + saveMotorGeometry, and saveCalibration() is the only caller.

10. Declaring presence

The existing code degrades gracefully everywhere — TestRunner skips the pitch and IMU leaves, CamEvent falls back to encoder pitch, HeadingFit is IMU-free by design, CaptureScheduler sends the single-axis MOVE Y … form. It adapts to what it finds and never states what it should have found, so a failure is indistinguishable from a configuration: a 2-axis tower whose pitch stops enumerating becomes a yaw-only tower, captures one elevation band, and reports itself healthy.

Declared Found Result
present present OK
present absent refuse to start, naming the subsystem; --degraded overrides, loudly
absent present warn — the file is stale

11. test hardware

Leaf Checks
test hardware axes every axis in axes appears in telemetry, and no others
test hardware motor DUMP reports the drivetrain values hardware.ini specifies — catches a stale flash, an unanswered NEED, or a tower left on limp-home defaults
test hardware imu declared → device opens, 0xFA-framed stream arrives
test hardware env declared → a real I2C adapter (i2cdetect -F says I2C = yes, not SMBus-only) and a CRC-valid SHT41 measurement — known-issues trap #2, documented but never automated
test hardware camera every camera in cameras is found; usbfs_memory_mb ≥ 1000; USB3 LPM off on the 1ab2 port

12. What stops being declared twice

Today After
Production speeds hardcoded in Application.cpp contradict firmware/src/config.h — and win fixed [Motor.<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 fixed derived from [Hardware] imu / env; the flags remain only for towers without a hardware.ini, and a flag that contradicts the declaration is warned about and ignored

mock_camera / mock_serial / mock_imu / mock_env stay in config.ini — dev-box substitution is operational, and it is what lets all of this be exercised with no hardware.

13. Migration

With no hardware.ini, synthesise one in memory from the current config.ini plus today's firmware defaults, and log the file it would have written. Ship install.sh --extract to split an existing device config. Reconciliation starts warn-only.

Deploy host-before-firmware (A.6). Fix the Application.cpp:1040 literals in the same change — on the existing fleet that drops yaw 50000 → 30000, a real behaviour change that wants a deliberate rig check rather than a silent correction.

⚠️ The Delta fleet is NOT migrated, and flashing it first would strand it

Checked 2026-09-23: tower 111 has no /etc/fire_gimbal_control/hardware.ini. Only tower 121 has been migrated. This is a live hazard rather than a leftover chore, because the firmware half is now written: an axis with no declared drivetrain sits in NEEDCFG and refuses to move. Flashing 111 with current firmware today would leave it unable to home, with nothing wrong that an operator could see on the board.

The ordering rule above is what prevents it, and it is not advisory:

  1. Write 111 a hardware.ini — hardware/lattepanda-iota/install.sh --stdout generates the shape; the Delta's own values come from its config.ini.
  2. Deploy the host and run test hardware. It must pass on the old firmware, which is the point of the _current/_declared metrics reporting an absent field as unconfirmable rather than as a mismatch.
  3. Only then flash the firmware, and re-run test hardware.

Until step 1 is done for every Delta tower, phase 1's gate — "test hardware green on every existing tower" — is not met, whatever 121 reports.

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

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
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. 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.

§5 is the exception, and it is deliberate. scan_speed_deg_s defaults to 8.0 deg/s and applies whether or not a hardware.ini exists, because the literals it replaced were themselves unconditional. Every tower changes speed on upgrade: the Delta fleet goes from 36.4 deg/s to 8.0, so a full revolution takes ~45 s instead of ~16.5 s. Check that against image_interval and the scan grid's waypoint count before deploying, and set scan_speed_deg_s per tower if 8.0 is not right for it. An axis whose counts_per_deg is untrustworthy is left on the firmware default instead.


Decisions needed before work starts

  1. How do the files find each other? Recommendation: a fixed /etc/fire_gimbal_control/hardware.ini, matching the existing credentials.env convention, rather than a pointer in config.ini (§4).
  2. The SPEED Y 50000 / SPEED P 150000 literals contradict the firmware's tuned-down defaults and win. Is the intended yaw production speed 30000 or 50000? ANSWERED, and the question turned out to be mis-framed. 30000 was intended for tower 111 — but a VMAX is not a portable quantity: the same 30000 is 21.8 deg/s on the Delta and 6.0 deg/s on the IOTA. Speed is now declared in degrees per second ([Motor.<axis>] scan_speed_deg_s, default 8.0) and converted per tower through counts_per_deg. The literals were 36.4 deg/s.
  3. NEEDCFG — refuse to move, or fall back after a timeout? Recommendation: refuse, with an explicit USE DEFAULTS for bench work. A timeout lets a slow host produce a silently mis-tuned tower (§6).
  4. How strict is reconciliation, and when? Recommendation: warn-only through the fleet rollout, then fatal, with --degraded as the field-triage escape (§10).
  5. Do any live rigs differ in axis count, or is the variation only IMU/env? If every tower is 2-axis today, axes guards against future drift rather than fixing something current, and phase 1 shrinks.