Compare commits

..

33 Commits

Author SHA1 Message Date
pgdalmeida 943f978a73
Home twice as fast, and tighten the stall threshold to 1000 2026-09-22 17:16:31 +02:00
pgdalmeida 7ef2abcd4a
Have the setup kit work out the centre a geared-up rig needs 2026-09-21 23:07:44 +02:00
pgdalmeida 57a166c9a4
Warn when the homed travel falls below zero, and say why the centre matters 2026-09-21 23:06:37 +02:00
pgdalmeida c2a2459f81
Show the firmware's reset-cause name in the gimbal view too, not just the dump 2026-09-21 21:55:34 +02:00
pgdalmeida 575e3c590b
Say when a paused dashboard was frozen, so it cannot be mistaken for a hung one 2026-09-21 21:53:04 +02:00
pgdalmeida e2bdc8debf
Trust the firmware's reset-cause name over a decode that cannot be right for both boards 2026-09-21 19:03:55 +02:00
pgdalmeida 816242725a
Add the handshake bring-up runbook (plus the mock/test changes) 2026-09-18 17:36:19 +02:00
pgdalmeida 07de495840
Say which LattePanda a tower actually has 2026-09-18 17:25:27 +02:00
pgdalmeida 041ad2a18f
Understand the NEEDCFG state instead of reading it as a dead link 2026-09-18 17:15:16 +02:00
pgdalmeida 6f88b00856
Write the drivetrain block at install, since the firmware now demands it 2026-09-18 17:06:34 +02:00
pgdalmeida 842d95eeca
Check every declared value, not just the current 2026-09-18 16:44:08 +02:00
pgdalmeida a554a3fd9c
Record the firmware half of the config handshake 2026-09-18 16:42:57 +02:00
pgdalmeida 522ba072ec
Answer the firmware's NEED lines from hardware.ini 2026-09-18 16:16:04 +02:00
pgdalmeida 579c4ec166
Let a declared sensor enable itself, and retire the duplicate flag 2026-09-18 15:57:10 +02:00
pgdalmeida b5d1fac4cb
Declare axis speed in degrees per second, not VMAX 2026-09-18 15:44:19 +02:00
pgdalmeida 4f57651c9b
Refuse to start when the rig is missing hardware it declares 2026-09-18 14:50:50 +02:00
pgdalmeida 2191a9d8cb
Record the resolved yaw homing and the hand-written drivetrain block 2026-09-18 13:19:32 +02:00
pgdalmeida f695a4eac6
Give the UTC stamp a buffer GCC can prove is big enough 2026-09-17 18:20:25 +02:00
pgdalmeida f9e209bf2a
Show the calibration actually in use, not just a fit from this session 2026-09-17 18:14:54 +02:00
pgdalmeida 31da8012d7
Stop addressing an axis this tower does not have, and carry the live calibration 2026-09-17 18:14:13 +02:00
pgdalmeida 5b3662c529
Document the drivetrain keys, the derived degree scale and the homing findings 2026-09-17 17:58:46 +02:00
pgdalmeida 0c1d0a6df8
Send and verify motor current, and derive counts_per_deg from the homed travel 2026-09-17 17:58:46 +02:00
pgdalmeida 9a61167aa6
Parse the motor current the firmware echoes back in DUMP 2026-09-17 17:58:46 +02:00
pgdalmeida ffaa41ac2f
Read the drivetrain per axis from hardware.ini, and compare INI values numerically 2026-09-17 17:58:45 +02:00
pgdalmeida 3476e5adc7
Convert motor current from milliamps to TMC register values 2026-09-17 17:58:45 +02:00
pgdalmeida 649d0979cf
Record the verified install results and the /tmp hardware.ini trap 2026-09-16 18:38:11 +02:00
pgdalmeida 9b232c3f60
Add a from-scratch tower installation guide with phased bring-up 2026-09-16 18:27:51 +02:00
pgdalmeida 2c7c62ace1
Add the LattePanda IOTA setup kit and headless camera profile export 2026-09-16 18:27:39 +02:00
pgdalmeida 08a6f7c1fa
Exclude hardware.ini from the deploy rsync so --delete cannot erase it 2026-09-16 18:27:27 +02:00
pgdalmeida b97df0adf2
Honour enable_camera and skip camera profiles when no real camera is used 2026-09-16 18:27:12 +02:00
pgdalmeida b0127dbde6
Stop tracking .deploy.env and ignore it, as its own docs already claim 2026-09-16 18:26:59 +02:00
pgdalmeida bcf54802cb
Move the hardware plans into the repo, where they can have a history 2026-09-16 18:26:13 +02:00
pgdalmeida 76e777f5af
Add hardware.ini assembly config, build identity in the header, and ;prev calibration history
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GsFaaLUTCYy3ZjaNTJzMv
2026-09-11 21:28:18 +02:00
67 changed files with 7691 additions and 103 deletions

View File

@ -1,5 +0,0 @@
# Local deploy config for the LattePanda. Not rsync'd to the device.
REMOTE_HOST=ggs@10.11.12.111
REMOTE_DIR=/home/ggs/projects/fwt_2a/software
CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON"
RUN_ARGS="--start"

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ bin/x64/Release/NIR/
# Real configs hold plaintext MQTT credentials. Commit config/config.example.ini instead.
config.ini
bin/x64/Release/config.ini
.deploy.env

View File

@ -35,6 +35,22 @@ find_package(Boost REQUIRED CONFIG COMPONENTS program_options)
find_package(PkgConfig REQUIRED)
pkg_check_modules(JXL REQUIRED IMPORTED_TARGET libjxl libjxl_threads)
# ---------------------------------------------------------------------------
# Build identity. deploy.sh computes `describe --always --dirty` and passes it in
# the environment (GIT_REV=... cmake ...), so a deployed binary can name the
# revision it came from in the TUI header. A plain local build has no GIT_REV and
# falls back to "unknown", exactly as firmware/src/main.cpp does.
#
# Read at CONFIGURE time: `cmake --build` alone keeps whatever value the last
# configure saw. deploy.sh reconfigures on every deploy, so the deployed path is
# always fresh.
# ---------------------------------------------------------------------------
if(DEFINED ENV{GIT_REV} AND NOT "$ENV{GIT_REV}" STREQUAL "")
set(FGC_GIT_REV "$ENV{GIT_REV}")
else()
set(FGC_GIT_REV "unknown")
endif()
# ---------------------------------------------------------------------------
# fgc_core: SDK-independent core (config, path resolution, and - as the
# refactor proceeds - logging, the capture scheduler and the I/O interfaces).
@ -47,6 +63,11 @@ add_library(fgc_core STATIC
src/core/SingleInstance.cpp
src/core/Logger.cpp
src/core/Geometry.cpp
src/core/TmcCurrent.cpp
src/core/HardwarePresence.cpp
src/core/HardwareChecks.cpp
src/core/AxisSpeed.cpp
src/core/FirmwareConfig.cpp
src/core/ScanGrid.cpp
src/core/TelemetryParser.cpp
src/core/CaptureScheduler.cpp
@ -67,6 +88,7 @@ add_library(fgc_core STATIC
src/core/Hash.cpp
src/core/ChannelHistory.cpp
src/core/ChannelTopics.cpp
src/sensors/I2cBus.cpp
src/sensors/Sht41Protocol.cpp
src/ui/UiSnapshot.cpp
src/ui/Scroll.cpp
@ -78,6 +100,8 @@ target_include_directories(fgc_core PUBLIC
${CMAKE_SOURCE_DIR} # for the bundled ini.h
)
target_link_libraries(fgc_core PUBLIC Threads::Threads)
# PUBLIC so the application and the tests both see the same build identity.
target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")
# ---------------------------------------------------------------------------
# Application executable. Camera image pipeline + serial controller always
@ -91,7 +115,6 @@ set(FGC_SOURCES
src/camera/ImagePipeline.cpp
src/serial/SerialMotorController.cpp
src/serial/MtiImuSource.cpp
src/sensors/I2cBus.cpp
src/sensors/Sht41EnvSensor.cpp
)
if(WITH_MQTT)
@ -170,4 +193,5 @@ message(STATUS " WITH_VIMBA : ${WITH_VIMBA}")
message(STATUS " WITH_MQTT : ${WITH_MQTT}")
message(STATUS " WITH_TUI : ${WITH_TUI}")
message(STATUS " tests : ${BUILD_TESTING}")
message(STATUS " build id : ${FGC_GIT_REV}")
message(STATUS "------------------------------------------------------------")

View File

@ -240,6 +240,9 @@ libjxl-dev`, plus the Vimba X SDK under `/opt/VimbaX` for `-DWITH_VIMBA=ON`.
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures |
| [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats |
| [docs/roadmap.md](docs/roadmap.md) | Forward-looking project roadmap |
| [docs/hardware-config-plan.md](docs/hardware-config-plan.md) | Splitting `hardware.ini` out of `config.ini`, and the firmware config handshake |
| [docs/hardware-porting-plan.md](docs/hardware-porting-plan.md) | Porting the tower to the LattePanda IOTA / RP2040 |
| [docs/lattepanda-iota.md](docs/lattepanda-iota.md) | Hardware facts for the IOTA board |
| [docs/test-roadmap.md](docs/test-roadmap.md) | Planned tests blocked on not-yet-integrated components |
## Repository layout

View File

@ -78,8 +78,9 @@ baud = 115200
; (an onboard hardware UART), so this is a stable /dev/ttyS* node - NOT a USB
; device and NOT /dev/ttyUSB0-3 (those are the Quectel modem). Find it with
; ls /dev/ttyS* ; dmesg | grep -iE 'ttyS|LPSS|HSUART' (often /dev/ttyS4)
; Enable with [Features] enable_imu = true. The host reconfigures the MTi to a
; Euler + calibrated 100 Hz stream at startup.
; Enable by declaring it in hardware.ini as [Hardware] imu = <model>; the legacy
; [Features] enable_imu applies only on a tower with no hardware.ini. The host
; reconfigures the MTi to a Euler + calibrated 100 Hz stream at startup.
device =
baud = 115200
@ -145,8 +146,9 @@ z = 0
; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the
; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus
; number varies by board; confirm with `i2cdetect -y N` (SHT41 shows up at
; 0x44). Enable with [Features] enable_env = true (or run with --mock-env,
; which implies it). i2c_addr takes decimal or 0x-prefixed hex.
; 0x44). Enable by declaring it in hardware.ini as [Hardware] env = <model>; the
; legacy [Features] enable_env applies only on a tower with no hardware.ini (or
; run with --mock-env, which implies it). i2c_addr takes decimal or 0x-hex.
i2c_device = /dev/i2c-1
i2c_addr = 0x44
period_ms = 2000
@ -168,12 +170,19 @@ period_ms = 2000
; Travel limits are NOT configured here - they are derived from the firmware's
; homed soft limits after every home, the only place they can be correct.
;
; Mechanical travel between the hard endstops now belongs in hardware.ini as
; [Motor.<axis>] travel_deg, where homing uses it to derive counts_per_deg below.
; Mechanical travel of this gimbal (between hard endstops, found at homing):
; Yaw ~320 deg (NOT a full revolution: there is an unreachable arc)
; Pitch 40 deg
;
; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33.
yaw_counts_per_deg = 983.33
; MUST exceed half the homed travel: the range is centre +/- half-travel, so a
; smaller centre puts the negative end below zero. 500000 suits the Delta
; (~89k half-travel); the IOTA's higher gear ratio needs 1000000. The centre
; itself is arbitrary - only the span is physical - and a warning names it
; after each home if it is too small.
yaw_center_count = 500000
; True bearing (0 = north) the gimbal points at when parked at the centre.
yaw_heading_at_center = 0.0
@ -190,6 +199,10 @@ pitch_angle_at_center = 10.0
pitch_soft_margin_counts = 5000
; Live encoder-error WARN threshold in counts (0 = off).
enc_error_warn_counts = 400
; How long a full two-end homing may take before the host calls it failed. A
; property of the drivetrain: a high gear ratio searching at a gentle speed takes
; minutes, and too tight a value reports a healthy axis as broken.
homing_timeout_s = 65
[Scan]
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).
@ -217,14 +230,27 @@ output_dir =
; Enable/disable subsystems and select mock (simulated) implementations.
; CLI flags (e.g. --no-mqtt, --mock-camera, --mock-serial) override these.
enable_mqtt = true
; false: open no camera at all - a mock source stands in, no Vimba X needed and
; no profile XMLs required. For bringing a tower up one subsystem at a time.
enable_camera = true
enable_serial = true
; LEGACY. On a tower with a hardware.ini these are derived from [Hardware] imu
; and [Hardware] env - declaring a sensor there is what enables it, and a flag
; here that contradicts the declaration is warned about and ignored. They remain
; only for towers that have not been migrated.
enable_imu = false
enable_env = false
; The mocks are NOT legacy: substituting a simulated device is a choice about
; this run, not a statement about what is bolted to the tower, so it stays here.
mock_camera = false
mock_serial = false
mock_imu = false
enable_env = false
mock_env = false
; Make the simulated motor controller run the config handshake: emit NEED lines
; at startup and reject HOME/MOVE/ENABLE until every requested key is answered
; from hardware.ini. How the host half is exercised before a firmware that asks
; exists - and afterwards, on a dev box with no rig.
mock_needcfg = false
[Test]
; Hardware self-test command (`test`). `profile` is the default profile used when
@ -235,6 +261,15 @@ mock_env = false
; here without code changes).
profile = standard
baseline_file =
; `test hardware camera` USB preconditions. Both are facts about the machine, not
; tuning. usbfs_memory_mb reverts to 16 on every reboot unless it is on the kernel
; cmdline, and frame delivery stalls below ~1000 (known-issues #19).
usbfs_min_mb = 1000
; The camera port's usb3_lpm_permit node. The path differs per board, so it cannot
; be guessed: left empty, the LPM check reports "not checked" rather than passing.
; Find it with the procedure in known-issues.md, e.g.
; usb3_lpm_path = /sys/devices/.../usb2/2-0:1.0/usb2-port3/usb3_lpm_permit
usb3_lpm_path =
[TestProfile.standard]
reps = 5 ; repetitions for homing / friction / balance

164
config/hardware.example.ini Normal file
View File

@ -0,0 +1,164 @@
; ===========================================================================
; hardware.ini - what this machine IS.
;
; Written once when the tower is assembled. Changing anything here means the
; hardware itself changed: a different board, a sensor fitted or removed, a
; cable moved to another port. Operational settings you tune to get better
; images - capture interval, camera profiles, scan grid, output paths - live in
; config.ini instead, and so does calibration, which is redone from time to time.
;
; WHERE THIS FILE GOES
; /etc/fire_gimbal_control/hardware.ini
;
; That is beside credentials.env, and it is deliberately OUTSIDE the directory
; deploy.sh rsyncs: a deploy cannot overwrite or delete what it never touches.
; The program looks, in order, at:
; --hardware <path> | $FGC_HARDWARE | /etc/... | beside config.ini
;
; Keys here override the same keys in config.ini, and any that are still set in
; both are named in a warning at startup so the stale copy can be deleted.
; ===========================================================================
; --- Which computer -------------------------------------------------------
[Board]
; Free-form, but keep it matching a directory under hardware/ so the setup kit
; for this board is easy to find.
model = lattepanda-3-delta
; PlatformIO env used to build the motor firmware for this board's MCU.
; lattepanda-3-delta / sigma -> leonardo (ATmega32U4, 5V logic)
; lattepanda-iota -> iota_rp2040 (RP2040, 3.3V logic)
firmware_env = leonardo
; --- What is fitted -------------------------------------------------------
; This is a DECLARATION, and its whole purpose is that startup can compare it
; with what it actually finds. The program already copes gracefully when a part
; is missing - the pitch axis, the IMU, the ambient sensor - which is correct for
; a rig built without them, and indistinguishable from a fault on a rig that has
; them. Saying what was assembled is what tells those two apart.
[Hardware]
; Axes this gimbal has. A yaw-only rig says: axes = yaw
axes = yaw, pitch
; Orientation sensor: a model name, or 'none'. Most of the fleet has none.
;
; Naming a model here is what ENABLES it - there is no second key to remember.
; [Features] enable_imu still exists for towers with no hardware.ini, but where
; this file declares a sensor the declaration wins, and a flag contradicting it
; is warned about and ignored.
imu = none
; Ambient temperature/humidity sensor: a model name, or 'none'. Same rule.
env = sht41
; The [Camera.<name>] blocks expected to be present and found.
cameras = rgb
;
; What happens when this does not match the rig:
; declared but absent -> warned about by name, and with [Features]
; strict_presence = 1 in config.ini the program refuses
; to start (exit 3). --degraded starts it anyway.
; found but undeclared -> warned about as a stale file. Never fatal.
; A tower with no hardware.ini at all is never reconciled, whatever
; strict_presence says: gaining this file must not change how a tower behaves.
; --- Where to find it -----------------------------------------------------
; Device bindings. These follow the hardware, not the tower, so they belong here
; rather than in config.ini.
[Serial]
; The motor controller MCU. Prefer the stable by-id symlink over /dev/ttyACM0,
; which is assigned in enumeration order and moves when USB devices change.
; Leonardo : /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
baud = 115200
[IMU]
; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi
; is on the RS-232 header, which is a hardware UART: a stable /dev/ttyS* node,
; NOT a USB device and NOT /dev/ttyUSB0-3 (those are the modem).
device = /dev/ttyS0
[Env]
; Only read when [Hardware] env is not 'none'. This is the board's OWN native
; I2C bus, not the motor controller's serial link. The bus number varies by
; board - it is /dev/i2c-2 on the 3 Delta, not the /dev/i2c-1 default.
;
; Two traps when finding it: i2cdetect false-negatives on the SHT4x (probe with
; a real measurement instead), and an SMBus-only adapter can never work at all
; (i2cdetect -F N reports 'I2C = no').
i2c_device = /dev/i2c-2
i2c_addr = 0x44
; --- What it is made of ---------------------------------------------------
; The drivetrain, one block per axis. A different motor, gearbox or driver board
; changes these; nothing an operator tunes belongs here.
[Motor.yaw]
; Sense resistor fitted to THIS axis's TMC5160 board. It is what makes the
; milliamp figures below mean anything, which is why the host - not the firmware -
; converts them into GLOBALSCALER/IRUN register values and sends those.
r_sense_ohm = 0.075
; Milliamps RMS per phase. run_current_ma is normal motion; homing_current_ma
; applies ONLY while hunting for an endstop.
;
; The two differ when the stop cannot survive full torque. On the IOTA tower the
; endstop is a screw head meeting another screw head, and the high gear ratio let
; the motor shear one past the other - it ran 1.8 turns past the stop without the
; encoder ever falling behind. At 189 mA it homes against the same screw cleanly.
;
; The startup log prints what the registers ACTUALLY deliver, which is not
; always what you asked for; believe the log, not this file.
run_current_ma = 756
homing_current_ma = 200
; Mechanical angle between the hard endstops. A NAMEPLATE FACT: homing measures
; the travel in counts, and dividing by this yields [Motor] yaw_counts_per_deg in
; config.ini, offered for saving at the y/n prompt.
;
; Deliberately a different key, in a different file, from counts_per_deg itself:
; hardware.ini is merged OVER config.ini and wins, so sharing a key would
; silently discard every calibration the save path writes back.
;
; ROUGH until somebody measures it. Every bearing inherits its error
; proportionally, until 'gimbal heading setup' supersedes it with a landmark fit.
travel_deg = 340
; Production move speed, IN DEGREES PER SECOND - not VMAX.
;
; A register value is not portable: VMAX 30000 is 21.8 deg/s on the Delta fleet
; and 6.0 deg/s on the IOTA, because their gear ratios differ by 3.6x. Both were
; carrying the same literal, which is how two towers ended up sweeping at
; completely different speeds while their configs looked identical.
;
; The host converts through yaw_counts_per_deg and sends SPEED after each home,
; so this follows the calibration automatically. An axis whose scale is not
; trustworthy is left on the firmware's default and the reason is logged.
scan_speed_deg_s = 8.0
; The other two motions, also in deg/s. Both happen BEFORE the first home, so
; they cannot use the counts_per_deg that homing derives - they convert through
; the scale this file implies (steps_per_rev / travel_deg), which on the IOTA
; agrees with the measured one to 0.25%.
homing_speed_deg_s = 4.0
center_speed_deg_s = 6.0
; --- Told to the firmware, which holds none of it itself --------------------
; The config handshake (docs/configuration.md): the firmware asks with NEED and
; these answer it. A key missing here leaves that axis in NEEDCFG, refusing to
; move, with the missing name logged - deliberately, because a drivetrain value
; guessed by the firmware is the duplication this removes.
;
; Expected full mechanical travel, IN COUNTS. Measured, not derived: it is the
; only guard against a PHANTOM endstop - too little homing current and the motor
; stalls on ordinary friction, trips the same latch a real stop does, and the
; bogus limit is accepted.
steps_per_rev = 1210778
; |XACTUAL - XENC| that means "position untrustworthy". 1000 here: the
; free-running gap measured 199-229 counts on tower 121, so this is ~4x headroom.
; Re-measure whenever the homing CURRENT changes - the gap is coupled to it, and
; notably NOT to speed (229 counts at VMAX 6000, 199 at 30000). Lowering is the
; safe direction; raising it blunts the only stall signal the rig has.
enc_deviation = 1000
; TMC SW_MODE - physical endstop switches on REFL/REFR. Yaw on this rig homes by
; StallGuard with no switches (0); an axis with real switches sets the bits.
sw_mode = 0
; Is this axis's encoder aligned to XACTUAL and usable for hold and DIAG?
has_encoder = true
[Camera.rgb]
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
; GigE IP address.
id = DEV_1AB22C049CD1

View File

@ -183,6 +183,14 @@ ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# config, operator-exported camera settings, captured output) and are
# deliberately NOT mirrored, so a deploy never clobbers them.
#
# hardware.ini is excluded for the same reason but one step further: it normally
# lives in /etc/fire_gimbal_control/, out of reach of this rsync entirely, and
# the exclude covers the fallback location (beside config.ini) that the
# resolution chain also accepts. Without it, --delete would erase a machine's
# assembly description on the next deploy - the same way it once erased a
# freshly exported camera profile. --push-config does NOT override this: the
# assembly of a tower is not something you push from a dev box.
#
# The profile XMLs matter especially: they are exported on the DEVICE from the
# VimbaX Viewer, which is where the camera is, so they exist nowhere in this
# repo. Without these excludes `--delete` erases them on the next deploy - which
@ -214,6 +222,7 @@ rsync -avz --delete \
--exclude '.vscode' \
--exclude '__pycache__' \
--exclude '.deploy.env' \
--exclude 'hardware.ini' \
"${CONFIG_EXCLUDES[@]}" \
--exclude 'config.ini.bak-*' \
--exclude 'config/camera_profiles/**/*.xml' \

View File

@ -49,12 +49,14 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) |
| `Features` | `enable_serial` | bool | `true` | (reserved) |
| `Features` | `enable_imu` | bool | `false` | Use the Xsens MTi orientation/IMU |
| `Features` | `enable_imu` | bool | `false` | **Legacy.** Derived from `[Hardware] imu` wherever that is declared; kept only for towers without a `hardware.ini`. A flag contradicting the declaration is warned about and ignored |
| `Features` | `mock_camera` | bool | `false` | Use the simulated camera |
| `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller |
| `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi |
| `Features` | `enable_env` | bool | `false` | Use the ambient temp/humidity sensor (SHT41) |
| `Features` | `enable_env` | bool | `false` | **Legacy.** Derived from `[Hardware] env` wherever that is declared; same rule as `enable_imu` |
| `Features` | `mock_env` | bool | `false` | Use the simulated env sensor instead of the SHT41 |
| `Features` | `mock_needcfg` | bool | `false` | Make the simulated motor controller run the config handshake (emit `NEED`, refuse to move until answered) |
| `Features` | `strict_presence` | bool | `false` | Refuse to start when `hardware.ini` declares a subsystem this rig does not have. Off during the fleet rollout; turn it on per tower once that file is known good. `--degraded` overrides it |
| `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
| `IMU` | `baud` | int | `115200` | MTi serial baud rate |
| `IMU` | `x` / `y` / `z` | float ±360 | `0` | Mounting rotation in degrees about forward/right/down, applied to every IMU reading (see below). All zero = the IMU is aligned with the gimbal |
@ -65,10 +67,19 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Logging` | `trace` | csv | — | Wire-trace categories, off by default (`--trace` overrides) |
| `UI` | `enable_tui` | bool | `false` | Full-screen terminal dashboard (`--tui`/`--no-tui` override; needs `WITH_TUI=ON`) |
| `Motor` | `yaw_counts_per_deg` / `pitch_counts_per_deg` | float | `983.33` / — | Encoder counts per degree (**calibrate**; may be negative to flip) |
| `Motor` | `yaw_center_count` / `pitch_center_count` | int | `500000` | Count homing **defines** as the middle of the found travel |
| `Motor` | `yaw_center_count` / `pitch_center_count` | int | `500000` | Count homing **defines** as the middle of the found travel. **Must exceed half the travel**, or the negative end falls below zero — the `500000` default suits the Delta's ~89k half-travel, not a geared-up rig (the IOTA needs 1000000). A warning names it after each home |
| `Motor` | `yaw_heading_at_center` | float | `0` | True bearing (0 = north) at that centre (**calibrate**) |
| `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig) |
| `Motor` | `yaw_soft_margin_counts` / `pitch_soft_margin_counts` | int | `5000` | Clearance kept from each hard endstop; sent to the firmware, which enforces it |
| `Motor` | `homing_timeout_s` | int | `65` | How long a full two-end homing may take before the host calls it failed. A property of the drivetrain — a high gear ratio searching gently takes minutes |
| `Motor.<axis>` | `r_sense_ohm` | float | — | Sense resistor on that axis's TMC5160 board. **hardware.ini.** Makes the milliamp figures below mean something |
| `Motor.<axis>` | `run_current_ma` / `homing_current_ma` | float | — | Coil current (mA RMS) for normal motion, and while hunting an endstop. **hardware.ini** |
| `Motor.<axis>` | `travel_deg` | float | — | Mechanical angle between the hard endstops. **hardware.ini.** Homing divides the measured travel by it to derive `<axis>_counts_per_deg` |
| `Motor.<axis>` | `scan_speed_deg_s` | float | `8.0` | Production move speed **in degrees per second**. **hardware.ini.** The host converts it to VMAX through `<axis>_counts_per_deg` and sends `SPEED`; a register value would not mean the same thing on two drivetrains |
| `Motor.<axis>` | `homing_speed_deg_s` / `center_speed_deg_s` | float | — | Endstop-search and park speeds, also in °/s. **hardware.ini.** Both are needed *before* the first home, so they convert through the declared scale (`steps_per_rev / travel_deg`), not the homed one |
| `Motor.<axis>` | `steps_per_rev` | int | — | Expected full mechanical travel, **in counts**. **hardware.ini.** The firmware's only guard against a phantom endstop |
| `Motor.<axis>` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **hardware.ini** |
| `Motor.<axis>` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** |
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate |
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
@ -395,6 +406,62 @@ generic `IEnvSensor` interface
([include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h)), so swapping in a different ambient sensor
later means adding a new backend, not touching `Application`, the UI, or MQTT.
### One key, not two: declaring a sensor is what enables it
`[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu`
and `enable_env` used to say the same thing a second time, and the two could
disagree — declare `imu = mti-630`, forget the flag, and the program would report
**missing hardware** for what was a missing config key.
The declaration now wins wherever it exists:
| `[Hardware] imu` | `[Features] enable_imu` | Result |
|---|---|---|
| *(absent)* | `true` / `false` | the flag governs, exactly as before — every un-migrated tower |
| `mti-630` | *(absent)* | **enabled.** No second key is needed |
| `mti-630` | `false` | **enabled**, and the clash is warned about so the stale key can be deleted |
| `none` | `true` | **disabled.** A rig that says it has no IMU cannot be argued into having one |
`mock_imu` / `mock_env` / `mock_camera` / `mock_serial` stay in `config.ini` and are
untouched by this: substituting a simulated device is an operational choice about
this *run*, not a statement about what is bolted to the tower.
### Declared vs found — what `hardware.ini` is checked against
Everything in this program degrades gracefully: `TestRunner` skips the pitch and IMU leaves, `CamEvent`
falls back to encoder pitch, `CaptureScheduler` sends the single-axis `MOVE Y` form. That is correct for a
rig that genuinely lacks them, and **indistinguishable from a failure on a rig that does not**. A two-axis
tower whose pitch stops enumerating becomes a yaw-only tower, captures one elevation band, and reports
itself healthy.
Declaring the assembly is what separates those two. At startup — once the motor, sensors and camera have
all been brought up, so "found" means *came up* rather than *was configured* — the program compares
`[Hardware]` against what it actually has:
| Declared | Found | Result |
|---|---|---|
| present | present | one `INFO` line confirming the assembly |
| absent | present | **warn**: `hardware.ini` is stale. Never fatal — more hardware than declared is a documentation fault |
| **present** | **absent** | **warn, or refuse to start under `strict_presence`**, naming each missing subsystem |
Two properties are deliberate:
- **A tower with no `hardware.ini` is never reconciled**, whatever `strict_presence` says. Gaining that
file must not be what changes how a tower behaves; nothing is compared until something is declared.
- **An empty `axes` states nothing**, matching `hasAxis()` answering true for everything when nothing is
said. Reconciling silence would turn a missing key into a fault.
`strict_presence` defaults to **off** so the fleet rollout cannot change behaviour on upgrade. Turn it on
per tower once that tower's file is known good; the fleet-wide flip is then a config change rather than a
release. When it refuses, the program exits **3** (distinct from a config error's 1 and the
single-instance lock's 2) before the control loop is entered.
`--degraded` overrides a refusal for field triage, and logs `RUNNING DEGRADED BY REQUEST` with the list of
what is missing — worded so it cannot be mistaken for a normal startup line.
> Which camera is missing is reported as a count (`2 configured, only 1 opened`), because the camera
> source reports how many it opened, not which. `test hardware camera` is what names the device.
### Secrets
`mqtt_user` / `mqtt_pw` are read from the environment variables **`FGC_MQTT_USER` / `FGC_MQTT_PW`** first,
@ -416,6 +483,7 @@ Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Feat
| `--mock-camera` | Use the simulated camera |
| `--mock-serial` | Use the simulated motor controller |
| `--mock-env` | Use the simulated ambient temp/humidity sensor (implies `[Features] enable_env`) |
| `--degraded` | Start even though declared hardware is missing (field triage; overrides `strict_presence` and says so loudly in the log) |
| `--tui` | Show the full-screen terminal dashboard (overrides `[UI] enable_tui`) |
| `--no-tui` | Force the headless line console (overrides config; wins over `--tui`) |
| `--log-level <lvl>` | `trace`/`debug`/`info`/`warn`/`error`/`off` |
@ -588,10 +656,137 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc
| `ENABLE Y` / `ENABLE P` | init | energize the axis coils |
| `HOME` | init | home all axes (firmware runs it non-blocking; watch `ST` state `R→H→A`) |
| `SPEED Y\|P <vel>` | init | set the production move speed (VMAX, counts/s) |
| `CURRENT Y\|P <scaler> <irun> <irun_home>` | init, before `HOME` | motor current as TMC register fields, converted by the host from `[Motor.<axis>]` milliamps |
| `CENTER Y\|P <count>` | init, before `HOME` | the count homing anchors the travel midpoint to |
| `MARGIN Y\|P <counts>` | init, before `HOME` | clearance kept from each hard endstop |
| `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) |
| `MOVE Y\|P <pos>` | — | single-axis absolute move (`set motorctl`) |
| `STOP Y\|P\|ALL` | — | ramp to a controlled stop |
### Motor current, and why the host computes it
The TMC5160 expresses coil current as two dimensionless registers, and what a given pair
delivers depends on the sense resistor soldered to the board. Only `hardware.ini` knows that
resistor, so the **host** converts milliamps to registers and sends the result; the firmware
keeps compile-time defaults so a bench board still behaves.
`homing_current_ma` exists for one reason: **an endstop the motor can out-push is not an
endstop.** Homing detects a stop by driving into it until the shaft falls behind the
commanded position. On the IOTA tower, whose gear ratio was raised for wind stability, the
motor simply sheared one screw-head stop past the other — it ran 1.8 turns past the stop with
the encoder never falling behind. At 189 mA it homes against the same screw cleanly.
The startup log names what the registers **actually** deliver, which is not always what was
asked for:
```
yaw motor current: run 756 mA (GLOBALSCALER 80, IRUN 31), homing 189 mA (IRUN 7)
-- R_sense 0.075 ohm from hardware.ini [Motor.yaw]
```
Because both registers are write-only on the chip, the firmware echoes them in `DUMP` and the
host checks them **before** homing. If an axis declares a reduced homing current and the
firmware has not adopted it, the host **refuses to home** rather than driving into a stop at
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.
```
firmware -> BOOT build=abc123
firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed ...
host -> SET Y steps_per_rev 1210778
host -> SET Y global_scaler 80
host -> SET Y normal_speed 39830
...
```
`SPEED`, `CENTER`, `MARGIN` and `CURRENT` remain as aliases, so an old firmware
and a hand-typed session both keep working; `center` and `margin` are accepted as
key names alongside `center_count` and `soft_margin` for the same reason.
Three rules, each protecting against a different silent failure:
- **A value the host does not have is left unanswered, never sent as zero.** A
zero here is a real setting — a zeroed current is a motor that cannot hold, a
zeroed VMAX is a stopped axis. The axis stays in `NEEDCFG` and the missing keys
are named at `ERROR`, because nothing but editing `hardware.ini` will make the
gimbal move.
- **A key the host has never heard of is reported separately** from one it simply
lacks. The first means the firmware is newer than the host and the fix is to
upgrade the host; the second means `hardware.ini` is incomplete. Conflating them
sends someone to edit the wrong file.
- **A partial answer does not unlock the axis.** Most keys landing is the common
case when a declaration is nearly complete, and it must not look like success.
> An axis waiting on its config reports `NEEDCFG` in the dashboard and `C` in the
> `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.
### Why speed is declared in degrees per second
`VMAX` is a register value, and what it *means* depends on the gear ratio:
| | Tower 111 (Delta) | Tower 121 (IOTA) |
|---|---|---|
| `counts_per_deg` | 983.33 | 3552.87 |
| `VMAX 30000` is | **21.8 °/s** | **6.0 °/s** |
Both towers were carrying the same literal `30000`, running at speeds differing by
3.6× — exactly their gear-ratio difference. A register value copied between towers
produces a silently different machine, which is why the speed is declared as the
physical quantity and converted per tower.
The conversion is `v[counts/s] = VMAX × f_CLK / 2²⁴` with `f_CLK` = 12 MHz, divided
by `counts_per_deg`. That single factor suffices because the firmware keeps
`XACTUAL` (step space) and `XENC` (encoder space) on the **same scale** — see
`firmware/docs/hardware-notes.md`, *"Encoders track 1:1 but must be aligned to
XACTUAL"*. The 0.7153 counts/s per VMAX unit this predicts is within 2% of the 0.73
measured across three homing runs on tower 121.
The speed is sent at the end of the init sequence, **after** `counts_per_deg` has
been re-derived from the travel just homed, so it always uses the current scale.
What is logged is what the axis will actually do, which differs from the request by
the integer quantisation:
```
yaw speed: 8.00012 deg/s (VMAX 32897) -- 2941.18 counts/deg
```
An axis whose scale is not trustworthy is **not commanded at all** — the firmware
keeps its compile-time default and the reason is logged. The floor is physical:
the AS5047P emits 4000 steps/rev in ABI mode, so even an ungeared axis reads 11.1
counts/deg, and anything below that is an uncalibrated axis still carrying
`AxisMap`'s default of 1.0 rather than a real drivetrain.
> **This replaced two hardcoded literals** — `SPEED Y 50000` and `SPEED P 150000`
> — which contradicted the firmware's own tuned-down defaults and won. On the
> Delta fleet that was 36.4 °/s against an intended 21.8 °/s.
### Deriving `counts_per_deg` from the homed travel
`travel_deg` is a nameplate fact: the mechanical angle between the stops. Homing measures the
same span in counts, so dividing gives the degree scale. On a successful home the host
compares the two and, when they disagree by more than a factor of two, adopts the derived
value and offers it at the usual `y`/`n` prompt, saving to `config.ini` with a `;prev` line.
Within a factor of two it only logs the comparison. That guard matters: a `gimbal heading
setup` landmark fit is a better measurement than this one, and a routine re-home must not
quietly overwrite it.
**The derived scale is only as good as `travel_deg`**, which is usually a rough figure — a 1 %
error in the angle is a 1 % error in every bearing. `gimbal heading setup` supersedes it.
Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both
axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan
grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as

View File

@ -1,9 +1,15 @@
# Deploying to the Gimbal LattePanda
Step-by-step guide to deploy `fire_gimbal_control` on a tower's LattePanda Sigma (Linux x86-64). Like the
Step-by-step guide to deploy `fire_gimbal_control` on a tower's LattePanda (Linux x86-64). Like the
firmware workflow, this is a repeatable **build → install → configure → enable → verify** cycle; the
[Update / redeploy](#9-update--redeploy) section at the end is the short loop you run for every new version.
> **Which LattePanda.** The deployed fleet is the **3 Delta** — read from tower 111's DMI
> `product_name`, *not* the Sigma, which this page claimed until 2026-09-18. Tower 121 is a
> **LattePanda IOTA**; see [lattepanda-iota.md](lattepanda-iota.md) and its setup kit under
> `hardware/lattepanda-iota/`. Nothing in this guide is board-specific — it is x86-64 Linux either way —
> so the distinction matters for the device paths in `hardware.ini`, not for these steps.
>
> Assumes a Debian/Ubuntu-based Linux on the LattePanda. For Arch/Manjaro swap `apt` for `pacman`
> (see [build-and-setup.md](build-and-setup.md)). Run as a normal user with `sudo` rights; the service runs
> under a dedicated `ggs` user.

View File

@ -0,0 +1,475 @@
# Hardware Configuration Plan
Splitting `config.ini` by lifetime, introducing an assembly-time `hardware.ini`, and letting the firmware
ask the host for the parameters it needs instead of carrying its own copies.
Draft for review, 2026-09-11. Covers `software/` and `firmware/`.
**This work stands on its own.** It fixes a live bug (§5), removes a class of silent degradation (§10)
and is worth doing whether or not the LattePanda IOTA port goes ahead. It is also a **prerequisite** for
that port — see [hardware-porting-plan.md](hardware-porting-plan.md).
## 0. The bindings this has to capture
Read live from the tower (`ggs@10.11.12.111`), not from the docs — the board is a **LattePanda 3 Delta**,
not the Sigma `docs/deployment.md:3` claims:
| Function | Binding | Backing hardware |
|---|---|---|
| Motor MCU | `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00` | USB `2341:8036`, onboard ATmega32U4 |
| IMU (Xsens MTi) | `/dev/ttyS0` | onboard RS-232 header |
| Ambient (SHT41) | `/dev/i2c-2` @ `0x44` | Synopsys DesignWare LPSS I2C |
| Camera | `DEV_1AB22C049CD1` | USB3 `1ab2:0001` Alvium 1800 U-2040c |
| Modem | `/dev/ttyUSB0-3` | USB `2c7c:0801` Quectel RM520N-GL, M.2 B-key |
Multiple rigs are in production, **mostly without an IMU**, so every change here has to be invisible on
rollout across a live mixed fleet.
---
## 1. Why split at all
The organising principle is **who writes this, and how often**:
| | Author | Changes | Goes to |
|---|---|---|---|
| **Assembly** | a human, once | only if someone physically changes the machine | `hardware.ini` |
| **Operation** | a human, continuously | whenever you tune | `config.ini` |
| **Calibration** | the program, on operator confirmation | from time to time — it assigns real-world orientation and drifts out of true | `config.ini` |
Three things it buys:
1. **Building a new tower is writing one file.**
2. **It survives deploys structurally.** `deploy.sh:234` keeps `config.ini` out of rsync by an exclude
rule someone has to maintain; `deploy.sh:227` records that `--delete` already destroyed a freshly
exported camera XML once. A file outside the deploy tree cannot be lost that way.
3. **It gives every fact exactly one home** — which is A.5 and A.6, and the largest part of this work.
## 2. Two files
```
/etc/fire_gimbal_control/hardware.ini # assembly — human, frozen, never deployed
<deploy tree>/config.ini # operation + calibration
```
Calibration stays in `config.ini`: it changes over time, so it is not assembly data. `--push-config`
stays as it is — a dev convenience for the tower under development.
## 3. What goes in `hardware.ini`
```ini
[Board]
model = lattepanda-3-delta ; or: lattepanda-iota
firmware_env = leonardo ; or: iota_rp2040
[Hardware]
axes = yaw, pitch ; or: yaw
imu = none ; or: mti — most of the fleet is 'none'
env = sht41 ; or: none
cameras = rgb
[Serial]
device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00
[IMU]
device = /dev/ttyS0
[Env]
i2c_device = /dev/i2c-2
i2c_addr = 0x44
[Camera.rgb]
id = DEV_1AB22C049CD1
; ── Drivetrain. A different motor or gear ratio changes all of these. ──
[Motor.yaw]
steps_per_rev = 177000 ; full mechanical travel reference
global_scaler = 80 ; TMC GLOBALSCALER — motor current
enc_deviation = 1500 ; yaw homing stall threshold (see A.5)
sw_mode = 0x08A0 ; hardware stop switches off (no physical endstops)
normal_speed = 30000
homing_speed = 12000
center_speed = 25000
soft_margin = 5000
[Motor.pitch]
steps_per_rev = 500000
global_scaler = 50
sw_mode = 0x08A3 ; endstops enabled
normal_speed = 60000
homing_speed = 150000
center_speed = 60000
soft_margin = 5000
```
`tower_name` belongs here too — assigned once, at assembly.
## 4. How the files find each other
**Recommendation: a fixed location, not a pointer in `config.ini`.**
```
--hardware <path> # explicit, for dev and testing
$FGC_HARDWARE # service environment
/etc/fire_gimbal_control/hardware.ini # the normal answer on a tower
<dir of config.ini>/hardware.ini # fallback, so a dev checkout just works
```
with the resolved path logged at startup. **`/etc/fire_gimbal_control/` is already your convention** —
`deployment.md §4` puts `credentials.env` there.
## 5. The duplication, and the bug it is already causing
> ### ⚠️ The host is silently undoing the firmware's speed reduction
> `Application.cpp:1040–1041`, at the end of every `--init`, unconditionally sends:
> ```
> SPEED Y 50000
> SPEED P 150000
> ```
> Hardcoded in host source. Meanwhile `firmware/src/config.h` sets `normal_speed` to **30000** for yaw —
> commented *"gentler top speed (was 50000)"* — and **60000** for pitch — *"(was 150000)"*.
>
> **The host sends exactly the old values the firmware deliberately moved away from.** The reduction has
> been clobbered on every init since it was made. Live on the fleet today.
Today the same fact exists in up to three places: `hardware.ini` (proposed), the firmware's compile-time
`MOTOR_CONFIG` table, and host source. The fix is not to synchronise them but to **delete two of them**.
### The gear ratio reaches further than speeds
Three more values change with the new yaw drivetrain, and none is a speed:
- **`enc_deviation` is the one that bears on damage.** Yaw homing detects the endstop when
`|XACTUAL − XENC|` exceeds it. `firmware/docs/hardware-notes.md` records the free-running gap as ~500
against a limit of 1500, and that *"lower = snappier (less grind)"*. **A different gear ratio changes
that free-running gap**, so it must be re-measured on the new tower — too low and homing false-triggers
mid-travel, too high and it grinds longer before noticing. Tuning homing speed alone solves half of it.
- **`steps_per_rev`** is what `HOMING_MIN_TRAVEL_SLACK` validates each home against.
- **`yaw_counts_per_deg` must be recalibrated.** The 983.33 default is derived from
`steps_per_rev = 177000`; on a different ratio it is wrong, so `gimbal heading setup` is mandatory.
## 6. The config handshake — the firmware asks
**The firmware should declare what it needs and refuse to move until it has it.** That removes the
firmware's parameter table as a competing definition, rather than trying to keep it in sync.
### Why this is a small change, not a new architecture
Both halves already exist:
- **The firmware already treats some parameters as host-owned.** `motor.h:70` reads *"host-owned homing
parameters (config.ini, sent as CENTER / MARGIN)"*.
- **The host already verifies the firmware adopted them.** `Application.cpp:1099` errors with *"the
firmware did not adopt CENTER (is it older than this host?) … Refusing to derive travel limits;
headings would be wrong"*, and `:1108` warns *"firmware reported no soft_margin (update firmware)"*.
So the pattern, the direction of ownership, and even the version-skew handling are established. What is
missing is that the firmware **guesses** when the host says nothing, instead of asking.
### The sequence
1. **Boot: pins and SPI up, then stop the motors.** Unconditional, no config needed — see the safety
note below.
2. **State `NEEDCFG`.** Motors stay disabled; `HOME` / `MOVE` / `ENABLE` are rejected with
`ERR need config`. Reported in `ST` and `DUMP`, so the dashboard shows *why* nothing moves.
3. **On host connection, ask.** `main.cpp` already reprints the boot banner whenever the host opens the
port — the natural hook. Emit alongside it:
```
BOOT build=abc123
NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed center_speed center margin
NEED P ...
```
4. **Host answers from `hardware.ini`** with a generic `SET <axis> <key> <value>`. `SPEED`, `CENTER` and
`MARGIN` stay as aliases so old hosts and hand-typed sessions keep working.
5. **When an axis's required set is complete**, the firmware applies the parameterised init block and
moves that axis to `ST_RESET`. `DUMP` reports every value it is holding, so the existing adoption
check generalises from "did CENTER land?" to "does the firmware hold what `hardware.ini` says?" —
which is `test hardware motor` in A.9.
### ⚠️ The safety constraint this must not break
`applyInitBlock` ends with, and `hardware-notes.md` explains why:
```cpp
tmc_.write(TMC_VMAX, 0);
tmc_.write(TMC_RAMPMODE, RAMP_VELOCITY_POS); // stop residual motion
tmc_.write(TMC_GSTAT, 0x07); // clear power-up latches
```
*"The TMC has its own supply and keeps running across an MCU-only reset (e.g. a re-flash), so it may
still be executing a velocity command from the previous session."* If the firmware waited for host
config before touching the driver, **a re-flash could leave the motor grinding until the host connects.**
So split the init block in two:
| Phase | Contents | When |
|---|---|---|
| **Safety init** | `VMAX=0` + velocity mode, `GSTAT` clear, and the constant register writes | at boot, unconditionally |
| **Parameterised init** | `GLOBALSCALER`, `XACTUAL = center`, `SW_MODE`, `ENC_DEVIATION` | once `NEEDCFG` is satisfied |
Those four are exactly the writes in `applyInitBlock` that read from `cfg_` or a tunable constant; the
rest are already fixed values.
### Standalone / bench operation
`hardware-notes.md`'s bring-up checklist drives the firmware by hand over `deploy.sh --monitor`, so a
firmware that refuses to move without a host would break it. Add an explicit
**`USE DEFAULTS`** command that loads a built-in **limp-home** set and logs loudly.
The distinction matters: limp-home values are **deliberately conservative — slow speeds, generous
margins — and never intended to match any real tower.** They are not a second definition of the
drivetrain, which is what today's `normal_speed = 30000` is. Nothing reaches production on them, and
`test hardware motor` fails if a tower is running them.
**Prefer this to a timeout fallback.** A firmware that waits *n* seconds and then adopts defaults means a
slow host silently produces a tower running on the wrong values — precisely the silent-degradation class
this whole design is removing. Make it an explicit act.
### What remains compile-time
Only **pin assignments** and the **axis count**: pins because SPI must be up before anything can be
asked or answered, the axis count because it sizes the axis array. Both are still defined once in
`hardware.ini` and *generated* into `firmware/src/config.h` at build — one definition, realised at build
time instead of over the wire. Everything else becomes runtime, which shrinks the build-time set far
below what I proposed last turn.
### Rollout order matters
A new firmware sitting in `NEEDCFG` in front of an old host would never be configured. So: **host first,
then firmware.** The host should treat a firmware that never sends `NEED` as an old one and fall back to
today's push-and-verify path, logging that it did.
## 7. Build identity in the TUI
No version negotiation — firmware and software are deployed in tandem. This section is display only.
### What already exists
Almost all of it. Both halves stop one step short.
**Firmware build id — travels end to end and is already on screen:**
- `firmware/platformio.ini` injects `GIT_REV` at build time, *"so the flashed firmware carries a build
identity"*, from `git describe --always --dirty`.
- The firmware reports it in `BOOT build=<rev>` (`main.cpp:39`) and `DUMP BEGIN build=<rev>` (`:103`).
- The host parses it into `DumpData.build` (`DumpParser.h:46`) and **already renders it in the gimbal
pane** — `TuiUi.cpp:999` draws `build <rev> up <ms> reset:<x>` on that overlay's header row.
Nothing to do here. It works today.
**Software build id — the slot exists and is filled with a placeholder:**
- `HeaderView` has a `build` field (`UiSnapshot.h:185`), and `TuiUi.cpp:1324` already renders
`text("build " + s.header.build) | dim` in the main header, right after the tower name — exactly where
you suggested putting it.
- `deploy.sh:212` computes `GIT_REV="$(git describe --always --dirty)"` and `:288` passes it into the
remote CMake invocation.
- But **`CMakeLists.txt` never reads `$ENV{GIT_REV}`**, so the value is passed and dropped, and
`Application.cpp:610` hardcodes `s.header.build = "dev"`.
Two edits close it:
1. `CMakeLists.txt` reads `$ENV{GIT_REV}` into a compile definition, defaulting to `"unknown"` — exactly
as `firmware/src/main.cpp:14` already does.
2. `Application.cpp:610` uses it instead of the `"dev"` literal.
```
FIREWATCH TOWER NorthRidge build 1a572cb LIVE up 412s
```
### One log line at startup
So an unattended tower records what it is running, where `journalctl` will find it. There is already a
house pattern — `Application.cpp:526` logs the IMU's identity the same way:
```
INFO Firmware: build a1b2c3d (from the BOOT banner, once, at connection)
```
Identity only. Nothing compares it to anything.
> **Two small notes.** The repo has **no tags**, so `git describe --always --dirty` currently yields a
> bare hash (`1a572cb-dirty`) — an honest build identity, but not a version. Tagging releases would make
> the same line read `v1.4.0-2-g1a572cb` at no extra cost, if you ever want it. And `$ENV{GIT_REV}` is
> read at CMake **configure** time, so a plain `cmake --build` after edits keeps the configure-time
> value; `deploy.sh:288` reconfigures on every deploy, so the deployed path stays fresh — and this
> matches how the firmware already behaves.
### Why no compatibility check
A maintained protocol number is one more thing to remember to bump, and a **forgotten bump is worse than
no number** — it asserts a compatibility that was never verified. The cases it would have caught are
already covered, behaviourally:
- **The handshake describes its own generation.** A firmware that never sends `NEED` (§6) is an old
one; the host falls back to today's push-and-verify path on that evidence alone. No number needed.
- **`test hardware motor`** (§11) asks the firmware to `DUMP` and confirms it is actually holding the
`hardware.ini` values — what the firmware *did*, not what it *claimed*.
- **`Application.cpp:1101` and `:1108`** already detect the two concrete incompatibilities anyone has hit
so far, and keep doing so unchanged.
## 8. Generated at install, not hand-written from scratch
```
hardware/
lattepanda-3-delta/
packages.txt # apt packages
preconditions/ # udev rules, sysctl.d, kernel-cmdline snippet
install.sh # apply preconditions; PROBE the device and emit hardware.ini
NOTES.md # quirks, verified-on date
lattepanda-iota/
...
```
`install.sh` fills in what it can detect — board model from DMI, the serial `by-id` path, the working
I2C bus (probed with a real SHT41 measurement, since `i2cdetect` false-negatives on the SHT4x), the
camera's `DEV_` id — and leaves a human to enter `axes`, `imu`, `env` and the drivetrain block, which no
probe can distinguish from a *fault*. Which is the whole of §10.
## 9. Calibration write-back
### Already implemented — the confirmation prompt
Nothing is written without a yes:
- `Application.cpp:944` raises the activity-strip prompt *"Save the new [Motor] calibration to config as
the default? (y / n)"* whenever `calib_save_pending_` is set.
- `Application.cpp:1260` / `:1726` set it after `gimbal heading setup` / `heading fit` / `pitch setup`.
- `Application.cpp:1847` implements `save` and `discard` as **commands**, so the answer works over the
TUI keypress (`TuiUi.cpp:1599`), stdin, or MQTT.
Worth checking on the rig: the headless/systemd path should log the values and expire, not hang.
### To add — keep the previous value as a comment
`updateIniSectionKeys` replaces a matching line **in place**, so the old value is lost. Keep exactly one
generation:
```ini
[Motor]
;prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal heading setup
yaw_heading_at_center = 127.4
```
Two rules keep it tidy: **only record a key whose value actually changed** (`saveMotorGeometry` rewrites
all eight every save, so without this the file gains eight lines of noise per calibration), and
**replace, don't accumulate** — an existing `;prev` for that key is overwritten, so the file grows once
and then holds steady. Reverting is uncommenting; `;prev` lines are comments and inih ignores them.
This lands in `updateIniSectionKeys` + `saveMotorGeometry`, and `saveCalibration()` is the only caller.
## 10. Declaring presence
The existing code degrades gracefully everywhere — `TestRunner` skips the pitch and IMU leaves, CamEvent
falls back to encoder pitch, `HeadingFit` is IMU-free by design, `CaptureScheduler` sends the single-axis
`MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a
failure is indistinguishable from a configuration**: a 2-axis tower whose pitch stops enumerating becomes
a yaw-only tower, captures one elevation band, and reports itself healthy.
| Declared | Found | Result |
|---|---|---|
| present | present | OK |
| **present** | **absent** | **refuse to start**, naming the subsystem; `--degraded` overrides, loudly |
| absent | present | warn — the file is stale |
## 11. `test hardware`
| Leaf | Checks |
|---|---|
| `test hardware axes` | every axis in `axes` appears in telemetry, and no others |
| `test hardware motor` | `DUMP` reports the drivetrain values `hardware.ini` specifies — catches a stale flash, an unanswered `NEED`, or a tower left on limp-home defaults |
| `test hardware imu` | declared → device opens, `0xFA`-framed stream arrives |
| `test hardware env` | declared → a **real I2C** adapter (`i2cdetect -F` says `I2C = yes`, not SMBus-only) and a CRC-valid SHT41 measurement — known-issues trap #2, documented but never automated |
| `test hardware camera` | every camera in `cameras` is found; `usbfs_memory_mb ≥ 1000`; USB3 LPM off on the `1ab2` port |
## 12. What stops being declared twice
| Today | After |
|---|---|
| ~~Production speeds hardcoded in `Application.cpp` 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.
## 14. Firmware changes, collected
- **Per board** → `platform_leonardo.h` / `platform_iota.h`: pin map plus the three AVR-isms
(`MCUSR`, `__heap_start`/`__brkval`) behind `platformResetCause()` / `platformFreeRam()`.
- **Generated** → pins and `NUM_AXES` from `hardware.ini`.
- **Deleted** → the drivetrain fields of `MOTOR_CONFIG`; `config.h` keeps only mechanism-wide thresholds.
- **New** → `NEEDCFG` state, `NEED` request line, generic `SET <axis> <key> <value>`, `USE DEFAULTS`,
split init block, drivetrain values reported in `DUMP`. The existing `GIT_REV` in `BOOT`/`DUMP` is
unchanged — no version numbers are added (§7).
---
# Sequencing
| Phase | Work | Gate |
|---|---|---|
| **1** | **Host**: split `hardware.ini` out of `config.ini` (§3), resolution chain (§4), presence reconciliation (§10), `test hardware` (§11), calibration `;prev` comments (§9), build identity (§7), `install.sh --extract` (§8). **Delete the `Application.cpp:1040` speed literals** (§5). Roll out across the live fleet, warn-only | `test hardware` green on every tower; header shows a real build id; an unplugged pitch axis *fails* instead of degrading silently |
| **2** | **Firmware**: `NEEDCFG` + `NEED` / `SET` / `USE DEFAULTS`, split init block, drivetrain values out of `MOTOR_CONFIG` and into `DUMP` (§6). **Host first, then firmware** | A firmware with no host sits in `NEEDCFG` and moves nothing; with the host it reaches `READY` holding exactly the `hardware.ini` values; an old firmware that never sends `NEED` is detected as such and falls back |
Phase 1 is deployable on its own. Phase 2 requires it.
## Progress
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
298 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` |
| 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 |
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.

View File

@ -0,0 +1,402 @@
# LattePanda IOTA Porting Plan
Draft for review, 2026-09-11. Covers both repos (`software/`, `firmware/`) plus the harness.
Hardware facts for the new board are in [`docs/lattepanda-iota.md`](lattepanda-iota.md).
> **Companion document:** [hardware-config-plan.md](hardware-config-plan.md) covers splitting `config.ini`,
> the `hardware.ini` assembly profile and the firmware config handshake. That work stands on its own and
> is a **prerequisite** for this port.
---
## 0. Baseline — what is actually deployed today
Read live from the tower (`ggs@10.11.12.111`) rather than from the docs:
| | Value |
|---|---|
| Board | **LattePanda 3 Delta** (DMI `product_name`) — *not* the Sigma |
| CPU | Intel Celeron N5105, 4C/4T |
| OS | Ubuntu 22.04.5 LTS, kernel 6.8.0-138 |
| RAM / disk | 8 GB / 57 GB eMMC, **67 % full** |
Device bindings in the live `config.ini`:
| 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** |
| Extra NIC | — | USB `0b95:1790` ASIX AX88179 GbE |
> **Doc bug — FIXED 2026-09-18.** `docs/deployment.md` said "LattePanda Sigma"; it is a 3 Delta. The page now names both boards the fleet actually runs (3 Delta on 111, IOTA on 121) and notes that nothing in that guide is board-specific — the distinction belongs to `hardware.ini`, not to the deploy steps.
---
# The port
> **What the configuration work changes for this port:** the IOTA is one new setup kit at `hardware/lattepanda-iota/`
> ([config plan §8](hardware-config-plan.md)) whose `install.sh` emits a `hardware.ini` with `[Board] model = lattepanda-iota` and the new
> device paths.
>
> **This is not only a board swap.** The new tower also has a **different yaw motor and gear ratio**, so
> its `[Motor.yaw]` block differs too: `steps_per_rev`, `global_scaler`, all three speeds, and
> `enc_deviation` ([config plan §5](hardware-config-plan.md)). Two consequences worth planning for:
> - **Calibration is mandatory, not inherited.** `yaw_counts_per_deg` is derived from `steps_per_rev`,
> so `gimbal heading setup` must be run on this tower before its headings mean anything.
> - **Yaw homing must be re-tuned before it is trusted.** It finds the endstop by driving into it, so
> both `homing_speed` and `enc_deviation_limit` want measuring on the real drivetrain — the free-running
> encoder gap that the limit is set against is a property of the gear ratio.
## 1. Verdict up front
The IOTA is a **performance and lifecycle upgrade** (N150 Gracemont vs N5105 Tremont, LPDDR5 with ECC,
3× USB 3.2 Gen 2, TPM, RTC), and the 10–15 V DC input matches the tower's 12 V rail. The compute story
is good.
The cost is concentrated in three places, in increasing order of pain:
1. **Firmware port** — real but small and well-bounded.
2. **Logic-level change, 5 V → 3.3 V** — turns out to be nearly free (see B.3).
3. **Three orphaned peripherals** — the IMU, the ambient sensor, and the 5G modem all lose the port they
currently use. The modem is the one with no clean answer.
## 2. Firmware port — ATmega32U4 → RP2040
**Pin budget is a non-issue.** The firmware needs 7 GPIO: 4 SPI + 2 chip-selects + 2 enables (the pitch
endstops go to the TMC's own `REFL`/`REFR`, not to the MCU). The IOTA breaks out 14 clean RP2040 pins.
**AS BUILT** on tower `10.11.12.121` (wired 2026-09-15). One TMC5160-BOB, yaw
only — the proposed map below it was never wired, and `src/platform_iota.h`
carries these numbers:
| Signal | RP2040 | TMC5160-BOB | Was (Leonardo) |
|---|---|---|---|
| Logic supply 3.3 V | 3.3V | pin 1 `VCC_IO` | 5 V |
| Signal ground | GND | pin 2 `GND` | GND |
| SPI CSN (yaw) | **GP1** | pin 3 `CSN` | D8 |
| SPI SCK | **GP2** | pin 4 `SCK` | ICSP |
| SPI MOSI | **GP3** | pin 5 `SDI` | ICSP |
| SPI MISO | **GP0** | pin 6 `SDO` | ICSP |
| Yaw DRV_ENN | **GP4** | pin 9 `DRV_ENN` | D7 |
These are a legal SPI0 group, which is not automatic: SPI0 takes SCK only from
{2,6,18,22}, TX from {3,7,19,23} and RX from {0,4,16,20}. GP1 is SPI0's hardware
CSn (the driver toggles it in software regardless) and GP4 is a plain output.
<details><summary>The originally proposed map, kept for the record</summary>
| Signal | RP2040 | Header pin | Was (Leonardo) |
|---|---|---|---|
| SPI SCK | GP2 | 9 | ICSP |
| SPI MOSI | GP3 | 10 | ICSP |
| SPI MISO | GP4 | 11 | ICSP |
| Yaw CSN | GP5 | 12 | D8 |
| Yaw DRV_ENN | GP6 | 13 | D7 |
| Pitch CSN | GP7 | 25 | D1 |
| Pitch DRV_ENN | GP8 | 26 | D0 |
| *(spare, I2C0 for a rehomed SHT41)* | GP12 / GP13 | 30 / 31 | — |
</details>
**Toolchain:**
```ini
[env:iota_rp2040]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = pico
framework = arduino
board_build.core = earlephilhower
monitor_speed = 115200
```
Note the IOTA's RP2040 has **8 MB flash** (Pico has 2 MB) — use a board definition with the right flash
size or override it, or the filesystem/OTA layout will be wrong.
**Code changes:** small and enumerable.
- `tmc5160.cpp`: call `SPI.setSCK/setTX/setRX(...)` *before* `SPI.begin()`. `SPISettings`,
`beginTransaction`, `transfer` are API-identical — the TMC driver logic is untouched.
- `main.cpp`: `MCUSR` → `rp2040.getResetReason()`; `freeRam()` → `rp2040.getFreeHeap()`.
- `F()` is a no-op on RP2040 — harmless, leave it.
- `Serial` is USB CDC on both — the host protocol and `command.cpp` are unchanged.
> ### ✅ Remote flashing — MEASURED on the board, 2026-09-15. Neither documented path works yet
>
> Tested end to end on tower `10.11.12.121`. Results first, the original research below:
>
> | Path | Board running MicroPython (factory) | Board running our firmware |
> |---|---|---|
> | **1200-baud touch** (`pio run --target upload`) | no reset — CDC does not implement it | **resets, but reboots into the APPLICATION, not BOOTSEL** — picotool waits and times out |
> | **`picotool reboot -f -u`** | refused — no picotool reset interface | refused — `Unable to locate reset interface on the device` |
> | **`BOOTSEL` serial command** (added 2026-09-15) | n/a | **works** — this is the answer |
> | **`machine.bootloader()` over the MicroPython REPL** | **works** — re-enumerates as `2e8a:0003 RP2 Boot` | n/a, MicroPython is gone once flashed |
>
> So the **first** flash of a factory board needs the REPL trick or a physical
> BOOTSEL press — one time per board, and worth knowing before someone drives to
> a mast. The **update** case is the one that still has no answer: our firmware
> honours the 1200-baud reset but returns to the application, so a tower cannot
> currently be re-flashed over the network at all.
>
> **Fixed by a `BOOTSEL` serial command** calling arduino-pico's
> `rp2040.rebootToBootloader()` — implemented and proven on tower 121 the same
> day. The full loop now runs with nobody at the board:
>
> ```
> firmware running --BOOTSEL over serial--> 2e8a:0003 RP2 Boot
> --deploy.sh over ssh---> firmware running
> ```
>
> It disables every axis first, and near misses (`BOOT`, `BOOTSE`) are rejected
> as unknown commands rather than acted on. The remaining exposure is accepted
> deliberately: anything that can write to the port — including the host's
> `gimbal raw` passthrough — can take the gimbal out of service until it is
> re-flashed. That is judged the lesser risk against a guaranteed site visit for
> every firmware update, because entering the bootrom erases nothing and the
> board is recoverable remotely.
>
> **One physical BOOTSEL press is still needed once per board**, to install the
> first image that carries the command.
>
> One precondition was also missing entirely and is now in the setup kit: a udev
> rule for `2e8a`, without which picotool finds the board and refuses it with
> *"unable to connect. Maybe try 'sudo'"*.
>
> <details><summary>Original research, 2026-09-11</summary>
>
> ### ⚠️ Remote flashing — researched 2026-09-11; workable, still to be proven on the board
> The Leonardo is flashed **over USB from the tower host** (`firmware/deploy.sh`) via the 1200-baud-touch
> reset. The RP2040's documented flow is **hold BOOTSEL, tap RST, drag a `.uf2`** — a person standing at
> the board. On a remote fire tower that is a serious regression.
>
> **Two independent paths exist, and the first is the same mechanism the Leonardo already uses:**
>
> 1. **1200-baud touch** — the Arduino standard (1200 bps on the CDC port = reset to bootloader).
> Arduino-Pico's upload tool issues the kick, waits for the board to re-enumerate as the `RPI-RP2`
> mass-storage device, and copies the `.uf2` across. Requires the running firmware to present a USB
> CDC port — which ours does, since that *is* the host link.
> 2. **`picotool reboot -f -u`** — forces a running board into BOOTSEL over USB. Also requires USB-CDC
> enabled in the running image.
>
> **What still has to be checked on the real board**, because the docs do not settle it:
> - Arduino-Pico's PlatformIO integration documents neither its default upload protocol nor whether it
> performs the 1200-baud touch automatically, so `firmware/deploy.sh` may need an explicit
> `upload_protocol`, or a `picotool reboot -f -u` step before `pio run --target upload`.
> - There are user reports of *"No accessible RP2040 devices in BOOTSEL mode were found"*, so the
> auto-reset is not bulletproof out of the box.
>
> **The failure modes are asymmetric, and that shapes the firmware:**
> - An **interrupted or failed flash self-recovers** — the RP2040 bootrom falls back to USB mass storage
> when flash holds no valid image, so the board returns as `RPI-RP2` by itself.
> - A **valid but broken image that never brings up USB CDC is unrecoverable remotely.** Neither path can
> reach it; only the physical `BOOTSEL` button can. That is the one that costs a site visit.
>
> Hence the design rule for the port: **bring `Serial` (USB CDC) up as early and unconditionally as
> possible** — before any hardware init that could hang, in particular before the SPI/TMC bring-up. A
> watchdog that resets the MCU if the main loop stalls earns its place for the same reason.
>
> </details>
## 3. Electrical — the 5 V → 3.3 V move is nearly free
The RP2040 is **not 5 V tolerant**, which sounds expensive. It isn't here, because the MCU's only
external connection is SPI + enable into the TMC5160-BOB, and:
- **`TMC5160-BOB` pin 1 `VCC_IO` accepts 3.3 V or 5 V** (`COMPONENTS.md` §3). Move it from the 5 V rail
to the IOTA's 3.3 V (header pin 17, 1.2 A budget). Every BOB logic pin then references 3.3 V.
- **`AS5047P` has a documented 3.3 V mode** (tie `VDD`/`VDD3V3`, supply 3.0–3.6 V — `COMPONENTS.md` §
encoder). It feeds the TMC's `ENCA/ENCB/ENCN`, not the MCU, so it only needs to match `VCC_IO`.
- **Pitch endstops** go to `REFL`/`REFR` on the BOB and follow `VCC_IO` automatically.
So: **move the whole logic domain to 3.3 V; no level shifters anywhere.** Motor supply `VS` (9–36 V) is
untouched. Budget the 3.3 V rail: 2 × BOB `VCC_IO` + 2 × AS5047P is tens of mA against 1.2 A — ample.
## 4. The three orphaned peripherals
### (a) IMU — Xsens MTi, today on `/dev/ttyS0`
The IOTA header does expose a PCH serial on pins 3/4, documented as **COM1 / `ttyS0`, 3.3 V**. If that
enumerates as `ttyS0` the config value doesn't even change.
| Option | Notes |
|---|---|
| **Header COM1 + MAX3232 transceiver** *(preferred)* | Keeps `[IMU] device = /dev/ttyS0`, costs no USB port. Needed **if** the MTi is true RS-232 levels; not needed if it is 3.3 V TTL |
| USB↔RS-232 adapter | Zero doubt, works today, but burns one of three USB ports and changes the device path to a `by-id` node |
**Action:** confirm the MTi model's signalling levels, and confirm the header enumerates as `ttyS0` (BIOS may gate it).
### (b) Ambient sensor — SHT41, today on `/dev/i2c-2`
**The published IOTA pinout exposes no PCH-side I2C** — the header's only PCH serial is COM1; all I2C is
RP2040-side. **[VERIFY against the schematic]**, because this determines the work.
| Option | Cost |
|---|---|
| **USB-I2C bridge (MCP2221A)** *(preferred for first deployment)* | Mainline `hid-mcp2221` registers a real `/dev/i2c-*` adapter. **Zero code change** — just repoint `[Env] i2c_device`. Costs a USB port |
| Rehome to the RP2040 (I2C0 on GP12/GP13) | Most integrated, frees the USB port, but needs firmware + a serial-protocol field + a new `IEnvSensor` implementation. Good follow-up, wrong thing to do during a board bring-up |
Either way, `test hardware env` ([config plan §11](hardware-config-plan.md)) should assert the bus is a real I2C adapter, not SMBus-only.
### (c) 5G modem — Quectel RM520N-GL, M.2 B-key
**The IOTA has no M.2 B-key slot.** This is the least-solved item.
| Option | Notes |
|---|---|
| USB-attached M.2 B-key enclosure | RM520N-GL supports USB 3.0 mode; keeps the existing modem and SIM. Costs a USB port and adds a box |
| External 5G/LTE router, IOTA on Ethernet | Cleanest separation, modem no longer the host's problem; changes the tower's network design and BOM |
| PCIe FPC | **Not viable** — the only FPC adapter is M-key, and that lane is already the NVMe path. One PCIe 3.0 ×1 lane, one device |
**USB port arithmetic:** 3 ports. Camera (1) + modem enclosure (1) + USB-I2C bridge (1) = full, with the
ASIX USB-NIC and any USB-RS232 adapter unplaceable. **An external router plus the header COM1 for the IMU
is the combination that fits.** Decide this before ordering.
## 5. Camera, USB and performance — re-measure, don't assume
Known-issue #19 and the acquisition work were characterised on the Delta's Jasper Lake xHCI. The N150 is
a different controller; the 20 MB-frame ceiling is a **host** finding and must be re-measured.
- Re-apply the preconditions: `usbfs_memory_mb=1000` (kernel cmdline) and USB3 LPM off via a udev rule on
`idVendor 1ab2`. Fold both into `hardware/lattepanda-iota/preconditions/` ([config plan §8](hardware-config-plan.md)) so they survive reboots by construction.
- Give the Alvium a port not shared with the modem or storage; re-run the frame-size/pacing sweep.
- Benchmark JPEG XL encode at the production frame size. The N150 should beat the N5105 comfortably, but
encode throughput sets the capture rate — measure it, don't infer it.
## 6. Storage, power, enclosure
- **Storage:** the Delta is at 67 % of 57 GB. Take the **128 GB** IOTA, and budget the **DFR1250 FPC→M.2
M-key adapter + NVMe** (~810 MB/s measured) if images are retained on the tower. Note this consumes the
PCIe lane — see B.4(c).
- **Power:** PH2.0 4-pin, 10–15 V DC — matches the existing 12 V rail. USB-C is **power input only**.
- **Thermal:** ~4 W idle, ~36 W peak. Settles ~70 °C at 2.2–2.3 GHz under sustained all-core load with the
active cooler. Consider the 6 W TDP mode for a sealed enclosure; the stock cooler is loud but that is
irrelevant on a tower.
- **Mounting:** 88 × 70 mm vs the Delta's 125 × 78 mm — new bracket and harness lengths.
---
# Sequencing
| Phase | Work | Gate |
|---|---|---|
| **0** | Buy one IOTA. Answer the 7 open questions in the datasheet §8 — especially **PCH I2C**, **COM1 levels/enumeration**, and **remote RP2040 flashing** | Any "no" here changes the BOM or the schedule |
| **1** | **Prerequisite:** [hardware-config-plan.md](hardware-config-plan.md) phases 1–2 — `hardware.ini`, the firmware config handshake, presence reconciliation and `test hardware`. Delivered against the Delta fleet first | `test hardware` green on every existing tower |
| **2** | Firmware port: `platform_*.h`, pins + `NUM_AXES` generated from `hardware.ini`, `[env:iota_rp2040]`; `native` tests stay green | Bench RP2040 + one TMC5160-BOB at 3.3 V: `IOIN` reads `version=0x30`, `ENABLE`/`MOVE` work under `USE DEFAULTS` |
| **3** | Bench-integrate the full gimbal on the IOTA: both axes, `HOME`, `DIAG` → PASS. **Re-measure the yaw free-running encoder gap and set `enc_deviation` from it; walk `homing_speed` down from a deliberately gentle value** | Firmware bring-up checklist in `firmware/docs/hardware-notes.md` passes; homing repeatable with no audible grind; `gimbal heading setup` run and saved |
| **4** | Host: Ubuntu 24.04, Vimba X, preconditions, peripherals rehomed, `test hardware` green | Full `--init --start` sweep with real camera + motors |
| **5** | Performance: frame-size/pacing sweep, JPEG XL encode benchmark vs the Delta | Meets or beats the Delta's rate |
| **6** | Field swap on one tower, Delta kept as rollback | 72 h clean run |
Phases 1 and 2 are independent and can run in parallel. Phase 1 has standalone value even if the IOTA is
rejected — it fixes the `deployment.md` drift and automates known-issue #19.
---
# Progress
Firmware branch `worktree-rp2040-platform` (worktree at `firmware/.claude/worktrees/rp2040-platform`),
commit `aab1925`.
| Phase | Item | State |
|---|---|---|
| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command |
| 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` |
| 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **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 | 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 |
| 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) |
> **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
> (`command_parse.cpp` and its native tests) compile and run under plain g++, and the rest
> syntax-checks against stub headers on both branches — the AVR under `gnu++11` and the RP2040 under
> `gnu++17`, with an unrecognised architecture failing on the `#error` rather than compiling silently.
> 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.
>
> 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
> take by surprise.
### Phase 3, first attempt — 2026-09-16
Three homing runs at VMAX 120, 1200 and 6000. What came out of them:
| Established | Value |
|---|---|
| Speed model | **~0.73 counts/s per unit of VMAX** (87 c/s at 120, 880 at 1200, 4400 at 6000 — predictions held to within 0.5 %) |
| Geometry | **~3,400 counts/degree**, so ~1.23 M counts per turn |
| `steps_per_rev` | The inherited Delta value of 177,000 is **wrong by ~7x** for this drivetrain. It is not a search limit (`motor.cpp` uses it only as a minimum-travel check once both limits are found), so it slowed nothing — but it made the axis look broken, and the check is meaningless until corrected |
**The axis turned ~2,157,000 counts — about 630 degrees, 1.8 full turns — in one
direction without the encoder deviation ever exceeding ~100 counts** against the
1500 that marks a jam. The motor met no resistance at any speed.
Homing detects an endstop by the shaft falling behind the commanded position, so
it cannot work if nothing stops the shaft. Two candidates were considered,
indistinguishable over the wire because the encoder sits on the motor side: no
hard stop at all, or an output not following the motor.
### RESOLVED, 2026-09-17 — the stop was there, and losing
Neither. **The endstop is a screw head meeting another screw head, and the raised
gear ratio gave the motor enough torque to shear one past the other.** The stop
was resisting; it simply lost, every time, and a stop that yields is invisible to
a detector watching for the shaft to fall behind.
The fix is to make the search weak enough that the screw wins:
| | |
|---|---|
| Homing current | **189 mA** (IRUN 7 at GLOBALSCALER 80, 75 mΩ sense resistor) |
| Run current | 756 mA — unchanged, because the gear ratio exists for wind stability |
| Measured travel | **1,210,778 counts**, repeatable to 0.27 % across three homes |
| Free-running encoder gap | ~200 counts against the 1500 latch, and it does **not** scale with speed |
| Full homing | ~2 min at VMAX 30000 |
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.
Three things this exposed, all since fixed:
- **`steps_per_rev` was inert**, still carrying the Delta's 177,000 against a real
turn of ~1.2 M, so it passed at 14 % of a turn. It is the only guard against a
*phantom* endstop, which reducing the current makes reachable.
- **`STOP` did not end a homing** — it halted the motor but left the state machine
waiting for a stall that could no longer come, until the timeout.
- **The host abandoned homing after a hard-coded 65 s**, which this drivetrain
needs more than. Now `[Motor] homing_timeout_s`.
<details><summary>What the investigation looked like before it was resolved</summary>
**Resolving it needs a physical observation**: did the gimbal itself rotate ~1.8
turns, or did the motor spin while the platform stayed put? Check any cabling
through that joint before moving the axis further.
</details>
---
# Decisions needed before work starts
Configuration decisions live in [hardware-config-plan.md](hardware-config-plan.md); these are the ones
specific to the hardware.
1. **Is the IOTA replacing the Delta fleet-wide, or becoming a second supported platform?** Either way
the configuration work stands alone and is worth doing first.
2. **Modem strategy** — external router (recommended; it also resolves the USB-port squeeze) vs USB M.2
enclosure. A BOM/network decision, not a software one.
3. **SHT41** — USB-I2C bridge now (zero code) vs rehome to the RP2040 (cleaner, later). Recommendation:
bridge now, rehome as a follow-up.
4. **Remote firmware flashing** — researched: two viable paths, both requiring the running image to
present USB CDC (§2). Phase 0 still has to prove one end-to-end on the board, and the firmware must
bring `Serial` up before anything that can hang.
5. **Confirm the new tower's drivetrain figures** — `steps_per_rev`, `global_scaler` and the rated motor
current for the new yaw motor and gear ratio. Everything in §2 and phase 3 depends on them.

199
docs/lattepanda-iota.md Normal file
View File

@ -0,0 +1,199 @@
# LattePanda IOTA — Hardware Notes
Collected 2026-09-11 from vendor documentation and independent reviews (sources at the bottom).
Companion to `COMPONENTS.md §1` (LattePanda 3 Delta, the board currently deployed on the tower).
> **Status: desk research.** Nothing here has been measured on a physical IOTA. Items marked
> **[VERIFY]** must be confirmed against the board or its schematic before anyone cuts a harness.
---
## 1. Why this board is different
The IOTA is not a drop-in successor to the 3 Delta. Two changes break existing assumptions:
1. **The co-processor changed from ATmega32U4 (8-bit AVR, 5 V) to RP2040 (32-bit Cortex-M0+, 3.3 V).**
The gimbal firmware is an AVR/Leonardo PlatformIO project; it must be ported, and **the RP2040 I/O
pins are not 5 V tolerant.**
2. **The expansion layout changed.** No M.2 B-key (the 5G modem's slot), no M.2 M-key (NVMe moves to a
16-pin FPC PCIe adapter board), and the header exposes only one PCH-side UART — no PCH I2C.
---
## 2. Core specifications
| Item | LattePanda IOTA | LattePanda 3 Delta (current tower board) |
|------|-----------------|------------------------------------------|
| CPU | Intel Processor **N150** "Twin Lake", 4C/4T, up to 3.60 GHz, 6 MB cache | Intel Celeron **N5105** "Jasper Lake", 4C/4T, 2.0–2.9 GHz |
| GPU | Intel Graphics, 24 EU, up to 1.0 GHz | Intel UHD, 450–800 MHz |
| TDP | Configurable **6 W – 15 W** | ~12 W typical |
| RAM | 8 GB / 16 GB LPDDR5 4800 MT/s, **in-band ECC** | 8 GB LPDDR4 2933 MT/s |
| Storage | 64 GB / 128 GB eMMC 5.1 + microSD | 64 GB eMMC 5.1 + microSD |
| TPM | TPM 2.0 (in CPU) | — |
| RTC | **Yes**, 2-pin 1.25 mm, CR2032 included | (V1 lacked one; Delta has one) |
| Dimensions | 88 × 70 × 19 mm (vendor page; CNX lists 88 × 77 × 19) | 125 × 78 mm |
| Power in | USB-C **PD 15 V** *(power input only)*, or PH2.0 4-pin **10–15 V DC** | 12 V DC (7.4–15 V) |
| OS support | Windows 10/11, Ubuntu 22.04 / 24.04 | Windows 10/11, Ubuntu, Android |
| Price | ~$129 board only; kits from ~$166 | — |
**Relevance to the fire-watch tower:** the N150 is a straight upgrade over the N5105 — newer Gracemont
cores, ~1.24× the clock ceiling, LPDDR5 vs LPDDR4. The 10–15 V DC input covers the tower's 12 V rail.
---
## 3. Co-processor: RP2040
| Item | RP2040 (IOTA) | ATmega32U4 (Delta) |
|------|---------------|--------------------|
| Core | Dual-core Arm Cortex-M0+ @ 133 MHz | Single-core 8-bit AVR @ 16 MHz |
| SRAM | 264 KB | 2.5 KB |
| Flash | 8 MB | 32 KB |
| **I/O logic level** | **3.3 V — NOT 5 V tolerant** | 5 V |
| Host link | **USB 2.0 CDC** (appears as a serial device) | USB 2.0 CDC (native USB) |
| Toolchains | Arduino C++, MicroPython, Firmata | Arduino C++ |
| Flashing | **UF2 bootloader**: hold `BOOTSEL`, tap `RST`, release → mounts as mass storage `RPI-RP2`, drag the `.uf2` | avrdude over the CDC port (Leonardo 1200-baud touch) |
**Internal RP2040 connections (not on the header):** GPIO20 = SW button, GPIO21 = RST, GPIO25 = blue LED.
### Host-side enumeration
The Delta's Leonardo enumerates as USB `2341:8036` → `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00`.
An RP2040 running the Arduino-Pico core enumerates as `2e8a:000a` (CDC) and `2e8a:0003` in BOOTSEL mode.
**[VERIFY]** the exact VID:PID and `by-id` string on the real board — the `[Serial] device` config value
and any udev rule key off it.
---
## 4. The 36-pin GPIO header (2×18, 2.54 mm)
| Pin | Signal | Dir | Level | Notes |
|-----|--------|-----|-------|-------|
| 1 | SPK+ | Out | — | Speaker, up to 2 W RMS / 4 Ω |
| 2 | SPK− | Out | — | |
| **3** | **TXD** | Out | **3.3 V** | **PCH serial, COM1 / `ttyS0`** |
| **4** | **RXD** | In | **3.3 V** | **PCH serial, COM1 / `ttyS0`** |
| 5 | GND | — | — | |
| 6 | 5V | Pwr | 5 V | Power Output 1, **0.5 A max** |
| 7–13 | GP0 … GP6 | I/O | 3.3 V | RP2040 GPIO |
| 14–16 | GP26 … GP28 | I/O | 3.3 V | ADC-capable, **100 nF to GND — unusable for I2C/SPI** |
| 17 | 3.3V | Pwr | 3.3 V | RP2040 rail, 1.2 A max |
| 18 | RST | In (pull-up) | 3.3 V | System reset, pull low |
| 19 | 5V | Pwr | 5 V | Power Output 2, **1.2 A max** |
| 20 | D− | I/O | — | USB 2.0, native PCH |
| 21 | D+ | I/O | — | USB 2.0, native PCH |
| 22 | GND | — | — | |
| 23 | S0 | Out | 3.3 V | OS-running indicator (200 Ω series, drives an LED) |
| 24 | S3 | Out | 3.3 V | OS-sleeping indicator (200 Ω series) |
| 25–31 | GP7 … GP13 | I/O | 3.3 V | RP2040 GPIO |
| 32 | RUN | In | 3.3 V | RP2040 reset, active low |
| 33 | SCLK | I/O | 3.3 V | RP2040 SWCLK (SWD debug) |
| 34 | SDIO | I/O | 3.3 V | RP2040 SWDIO (SWD debug) |
| 35 | GND | — | — | |
| 36 | SW | In (pull-up) | 3.3 V | System power button, pull low |
**Warnings from the vendor docs**
- Do not draw from 5 V Power Output 1 and Output 2 simultaneously.
- GP26–GP28 carry 100 nF to ground for ADC stability; avoid them for I2C or any fast edge.
- Do not apply >3.3 V to any RP2040 pin.
### Usable RP2040 GPIO budget
**GP0–GP13 (14 clean pins)** plus GP26–GP28 (analog-only in practice). GP14–GP25 are not broken out.
RP2040 peripheral options **within GP0–GP13**:
| Peripheral | Available pins in GP0–GP13 |
|------------|----------------------------|
| SPI0 | SCK GP2/GP6, TX(MOSI) GP3/GP7, RX(MISO) GP0/GP4, CSn GP1/GP5 |
| SPI1 | SCK GP10, TX(MOSI) GP11, RX(MISO) GP8/GP12, CSn GP9/GP13 |
| I2C0 | SDA GP0/GP4/GP8/GP12, SCL GP1/GP5/GP9/GP13 |
| I2C1 | SDA GP2/GP6/GP10, SCL GP3/GP7/GP11 |
| UART0 | TX GP0/GP12, RX GP1/GP13 |
| UART1 | TX GP4/GP8, RX GP5/GP9 |
Any RP2040 pin can also be PWM or PIO. **[VERIFY]** against the published pinout — the peripheral map
above is from the RP2040 datasheet, not from LattePanda.
---
## 5. I/O and expansion
| Interface | IOTA | Delta (for comparison) |
|-----------|------|------------------------|
| USB Type-A | **3 × USB 3.2 Gen 2 (10 Gbps)** | 1 × 3.2 Gen2 + 2 × 3.2 Gen1 |
| USB header | USB 2.0 pin header (pins 20/21) | — |
| USB-C | **Power input only (PD 15 V)** — no data, no DP-alt | USB 2.0 Type-C + DP 1.4 alt |
| Ethernet | 1 × GbE RJ45, WoL (**Realtek, `r8169`**) | Intel I225-V GbE |
| Video | HDMI 2.1 (4K@60), eDP 1.4b 2-lane (1080p60) | HDMI 2.0b, DP via USB-C, eDP 30-pin |
| **M.2 E-key 2230** | PCIe 3.0 ×1 / USB 2.0 / CNVio2 — Wi-Fi/BT | present |
| **M.2 B-key** | **ABSENT** | present (2242/2252/2280, SATA/USB2/USB3/SIM) |
| **M.2 M-key** | **ABSENT** — use the FPC PCIe adapter | present (2280, PCIe 3.0 ×2) |
| PCIe FPC | 16-pin, 0.5 mm, **PCIe 3.0 ×1**, 2.2 A shared across two 5 V pins | — |
| microSD | TF slot | Micro-SD + Micro-SIM combo |
| Audio | 3.5 mm jack, speaker via header, HDMI digital | 3.5 mm combo |
| Fan | 4-pin 1.25 mm, PWM, 5 V | — |
| UPS | 10-pin 1.25 mm USB 2.0 connector | — |
### Known expansion boards
- **DFR1250 — M.2 M-key expansion**: converts the 16-pin FPC to an M.2 M-key slot, PCIe 3.0 ×1 (8 GT/s),
2230 and 2280, bus-powered, no drivers. **NVMe only — SATA M.2 SSDs are not supported.**
Measured ~810 MB/s in an independent review.
- PoE expansion board — works as a standalone gigabit interface.
- UPS board — **reported working only on Windows 10/11; Linux NUT did not see a USB device.**
---
## 6. Linux behaviour (independent report, Nov 2025)
- Fedora Server 43 / kernel 6.17.7 installed cleanly, "no quirks or oddities". Ubuntu 22.04/24.04 are
the vendor-supported targets.
- Ethernet is Realtek via `r8169`.
- eMMC: ~316 MB/s sequential read, ~175 MB/s write, ~0.15 ms read latency.
- NVMe over the FPC adapter: ~810 MB/s.
- Power: ~4.0 W idle; 24.4 W under disk stress; 36.3 W peak under combined CPU+disk, dropping as the CPU throttles.
- Thermals with the active cooler: ~80 °C initially under sustained all-core load, settling ~70 °C at
2.2–2.3 GHz; ~2.9 GHz on brief single-core bursts. **The stock active cooler is loud** ("very strong
high-pitched whine") — relevant only if anyone has to sit next to it.
- 24 h stress with the thermal-pad film accidentally left on produced no throttling.
- 3 USB storage devices ran ~24 h / 72 TB of reads with no kernel instability.
- RTC present, so the systemd timer problems seen on the V1 are gone.
---
## 7. Migration notes (vendor "V1 → IOTA" guide)
- **Voltage:** 5 V peripherals now need level shifting. Explicit warning against >3.3 V on any pin.
- **Sketches:** existing Arduino code "needs porting" — library support differs, port-manipulation code
differs, timing and interrupt handling diverge from 8-bit AVR.
- **Display:** MIPI-DSI → eDP 1.4b; the 7-inch MIPI display (FIT0477) is incompatible (replacement FIT1030).
- **Power:** micro-USB 5 V → USB-C PD 15 V; internal connector now 10–15 V DC.
- The guide does **not** cover bootloader details, Arduino IDE board selection, upload-method changes, or
a library compatibility matrix.
---
## 8. Open questions — confirm on real hardware
| # | Question | Why it matters |
|---|----------|----------------|
| 1 | Is there **any** PCH-side I2C/SMBus reachable on the IOTA? The published header shows only COM1. | The SHT41 ambient sensor lives on the Delta's `/dev/i2c-2` (Synopsys DesignWare LPSS). With no PCH I2C it must move to the RP2040 or to a USB-I2C bridge. |
| 2 | Is header COM1 (pins 3/4) **3.3 V TTL or true RS-232 levels**? Docs say 3.3 V. | The Xsens MTi is on the Delta's RS-232 header at `/dev/ttyS0`. A level mismatch needs a transceiver or a USB-serial adapter. |
| 3 | Exact RP2040 VID:PID and `/dev/serial/by-id/` string. | `[Serial] device` and the udev rule. |
| 4 | Does the header COM1 enumerate as `ttyS0` under Linux, and is it enabled in BIOS? | IMU device path. |
| 5 | Can the Quectel RM520N-GL (M.2 B-key) be rehomed? | No B-key slot on the IOTA. |
| 6 | USB3 LPM and `usbfs_memory_mb` behaviour on the N150 xHCI. | Known issue #19 — the Alvium stalls without these. |
| 7 | Sustained JPEG XL encode throughput at the production frame size. | Sets the achievable capture rate. |
---
## Sources
- [LattePanda IOTA product page](https://www.lattepanda.com/lattepanda-iota)
- [LattePanda Docs — IOTA Internal Interface (36-pin pinout)](https://docs.lattepanda.com/content/iota_edition/io_playability_Internal/)
- [LattePanda Docs — IOTA RP2040 Programming](https://docs.lattepanda.com/content/iota_edition/rp2040_programming/)
- [LattePanda Docs — Migration Guide (V1 to IOTA)](https://docs.lattepanda.com/content/iota_edition/migration_guide_v1_to_iota/)
- [CNX Software — LattePanda IOTA SBC combines Intel N150 with RP2040](https://www.cnx-software.com/2025/09/18/lattepanda-iota-sbc-combines-intel-n150-soc-with-raspberry-pi-rp2040-for-gpio-and-power-control/)
- [LinuxGizmos — LattePanda IOTA brings Intel N150 and RP2040 co-processor](https://linuxgizmos.com/lattepanda-iota-brings-intel-n150-and-rp2040-co-processor-in-palm-sized-sbc/)
- [DFRobot — LattePanda IOTA 8GB/64GB](https://www.dfrobot.com/product-2989.html)
- [DFRobot Wiki — DFR1250 M.2 M-Key Expansion Board](https://wiki.dfrobot.com/SKU_DFR1250_M2_M_Key_Expansion_Board)
- [techtipsy — LattePanda IOTA review: how does it perform as a home server?](https://ounapuu.ee/posts/2025/11/18/lattepanda-iota/)
- [CNX Software — LattePanda Sigma (ATmega32U4 reference)](https://www.cnx-software.com/2023/04/26/lattepanda-sigma-intel-core-i5-1340p-raptor-lake-sbc-atmega32u4-arduino-mcu/)
- [Arduino-Pico (earlephilhower) — PlatformIO integration](https://arduino-pico.readthedocs.io/en/latest/platformio.html)

View File

@ -14,7 +14,12 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional<MotorTelemetry>` |
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
| [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) |
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` |
| [include/fgc/TmcCurrent.h](../include/fgc/TmcCurrent.h), [src/core/TmcCurrent.cpp](../src/core/TmcCurrent.cpp) | Milliamps → TMC5160 `GLOBALSCALER`/`IRUN` register fields, using the sense resistor from `[Motor.<axis>]`. Pure arithmetic, so it is unit-tested; the host owns the conversion because only it knows the resistor |
| [include/fgc/HardwarePresence.h](../include/fgc/HardwarePresence.h), [src/core/HardwarePresence.cpp](../src/core/HardwarePresence.cpp) | `reconcilePresence` (what `hardware.ini` declares vs what actually came up) + `presenceVerdict` (proceed / warn / refuse, per `strict_presence` and `--degraded`). Pure comparison, so the policy is unit-tested without a rig |
| [include/fgc/HardwareChecks.h](../include/fgc/HardwareChecks.h), [src/core/HardwareChecks.cpp](../src/core/HardwareChecks.cpp) | The verdicts behind `test hardware`: `checkUsbfsMemory`, `checkUsb3Lpm`, `checkI2cFunctionality`, `checkDrivetrainAdopted`. Pure, so the thresholds that encode known-issues #2 and #19 cannot drift unnoticed |
| [include/fgc/AxisSpeed.h](../include/fgc/AxisSpeed.h), [src/core/AxisSpeed.cpp](../src/core/AxisSpeed.cpp) | `planAxisSpeed` (deg/s -> TMC `VMAX` through `counts_per_deg`) + `vmaxToDegPerSec`. Pure arithmetic; refuses an uncalibrated scale rather than commanding a near-zero speed |
| [include/fgc/FirmwareConfig.h](../include/fgc/FirmwareConfig.h), [src/core/FirmwareConfig.cpp](../src/core/FirmwareConfig.cpp) | `parseNeedLine` (firmware `NEED` line -> axis + keys) + `planFirmwareConfig` (-> the `SET` lines that answer it). Reports a key it cannot answer separately from one it does not recognise: incomplete `hardware.ini` vs. a firmware newer than the host |
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump`. Prefers the firmware's own `reset=` name over any host-side decode - `mcusr` is an AVR bitmask on one board and a plain enum on the other, so a single table cannot be right for both |
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
| [include/fgc/PitchSetup.h](../include/fgc/PitchSetup.h), [src/core/PitchSetup.cpp](../src/core/PitchSetup.cpp) | `gimbal pitch setup` worker thread: home-if-needed → pitch sweep dwelling on the gravity-referenced IMU pitch → least-squares fit → park level; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()`. Yaw is not touched |
@ -81,7 +86,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
### `MotorTelemetry` / `AxisTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state`
(`AxisState` B/R/H/A/E), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags
(`AxisState` B/R/H/A/E/C — the last being NEEDCFG, a firmware waiting to be told its drivetrain), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags
`standstill`/`stall`/`deviation`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers.
Parsed from the firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.

View File

@ -29,6 +29,7 @@ token that names a configured profile selects the profile at any position.
| `test gimbal homing` | just homing |
| `test imu drift` | just IMU yaw drift |
| `test host` | host thermal/disk/memory/load (no hardware needed) |
| `test hardware` | is this rig the one `hardware.ini` declares? (no homing needed) |
| `test gimbal encoder strict` | one leaf, `strict` profile |
| `test strict` | everything, `strict` profile |
@ -52,16 +53,69 @@ While a test runs, interfering commands (moves, homing, a second test, capture,
| `host` | `disk` | free space on the image partition (`statvfs`) |
| `host` | `memory` | available RAM / swap (`/proc/meminfo`) |
| `host` | `load` | load average vs CPU count |
| `hardware` | `axes` | the axes `[Hardware] axes` declares appear in telemetry, **and no others** |
| `hardware` | `motor` | `DUMP` echoes **everything** `hardware.ini` declares — current, `steps_per_rev`, `enc_deviation`, `sw_mode` and all three speeds — catching a stale flash, a lost `SET`, or an axis that never left `NEEDCFG` |
| `hardware` | `imu` | a declared IMU is connected and a sample arrives |
| `hardware` | `env` | a declared ambient sensor sits on a **real I2C** adapter (not SMBus-only) and returns a CRC-valid reading |
| `hardware` | `camera` | every configured camera opened; `usbfs_memory_mb` and USB3 LPM hold |
Gimbal leaves require a homed gimbal (`home` first); IMU leaves require the IMU
(`[Features] enable_imu`). `host` needs neither, so `test host` doubles as a fast
non-intrusive smoke check.
**`hardware` deliberately does not need a homed gimbal either** — it reads telemetry
and `DUMP` and moves nothing, because it is what you run on a tower that has just
been installed and *before* you trust it enough to home it.
> Some metrics are most accurate with firmware support (a homing-report `HM` line
> and a load-capture `LP` primitive); until those land the modules fall back to
> sampling the `ST` telemetry stream and note it in the report. See
> [test-roadmap.md](test-roadmap.md).
### What `test hardware` is for
The other subsystems measure things and trend them. `hardware` asks a different
question — **is this rig the one `hardware.ini` says it is?** — so its metrics are
1/0 verdicts and the sentence that matters lives in the notes:
```
[hw_motor] FAIL 0 ms # firmware adopted the declared drivetrain
yaw_current = 0 FAIL
; yaw_current: yaw: firmware did not echo gs/irun/irun_home -- it predates the
CURRENT command, so whatever hardware.ini asks for is NOT being applied. Re-flash it
; pitch_current: not checked -- no [Motor.pitch] current declared
```
Three rules hold across every leaf:
- **Nothing declared reports `SKIP`, never `PASS`.** A check that did not happen
must not look like one that succeeded — that is how a precondition regression
survives a green run. An un-migrated tower with no `hardware.ini` skips the lot
and still reports `all_pass`.
- **Unreadable is a failure.** An empty or unparseable `usbfs_memory_mb` fails
rather than passing by default, for the same reason.
- **The detail names the fix, not the symptom.** "SMBus-only, point `[Env]
i2c_device` at a real adapter" rather than "sensor not responding" — the two
look identical from the sensor's side, and the distinction is what
known-issues #2 cost to learn.
The `motor` leaf reports two metrics per axis. `<axis>_current` covers
`GLOBALSCALER`/`IRUN`/`homing IRUN`; `<axis>_declared` covers everything else the
host owns. Both treat an **absent** field as unconfirmable rather than as a
mismatch — comparing against a missing value would report "firmware has -1", which
reads as a wrong setting and sends someone to re-send a command that was never the
problem.
The `motor` leaf is the one worth running after every flash: `GLOBALSCALER` and
`IRUN` are **write-only** on the TMC5160, so the `DUMP` echo is the only evidence
a `CURRENT` command landed. A homing current that silently did not take is the
case that destroys an endstop.
| Key | Default | For |
|---|---|---|
| `[Test] usbfs_min_mb` | `1000` | What `usbfs_memory_mb` must reach. It reverts to 16 on every reboot unless it is on the kernel cmdline |
| `[Test] usb3_lpm_path` | *(empty)* | The camera port's `usb3_lpm_permit` node. Differs per board, so it cannot be guessed; empty means the LPM check reports "not checked" rather than passing |
## Profiles
A profile is a named bundle of repetition counts, sampling windows and pass

898
docs/tower-installation.md Normal file
View File

@ -0,0 +1,898 @@
# Installing a tower from scratch
How a new fire-watch tower host goes from a freshly imaged machine to a running,
calibrated `fire_gimbal_control`. Written while doing exactly that on the IOTA
tower at `10.11.12.121` (2026-09-15) and kept honest about which steps have
actually been exercised and which are still on paper.
> [deployment.md](deployment.md) is the *update* loop for a tower that already
> works. This is the *first* install, including the parts that only happen once:
> the board's preconditions, the Vimba X SDK, `hardware.ini`, and the motor
> firmware.
**Status legend** — each step says where it stands:
`[verified]` run end to end on a real tower and the result checked ·
`[partial]` run, with something still open · `[untested]` written from the
design, not yet exercised.
---
## What you need before starting
| | |
|---|---|
| The tower host | Powered, on the network, reachable by IP, with an OS installed and an `ssh` server running |
| An account on it | `ggs`, in the `sudo` and `dialout` groups |
| A dev box | This repository checked out, plus a copy of the Vimba X SDK (login-walled, so it cannot be fetched by a script) |
| The hardware | Camera plugged in, motor MCU plugged in, and a note of **what was assembled**: which axes, which IMU, which ambient sensor |
Two facts decide most of what follows, so establish them first:
```bash
ssh ggs@<tower-ip> 'cat /sys/class/dmi/id/product_name; . /etc/os-release && echo "$PRETTY_NAME"'
```
| Board | MCU | Setup kit | PlatformIO env |
|---|---|---|---|
| LattePanda 3 Delta / Sigma | ATmega32U4 (Leonardo), 5 V | *not written - the Delta fleet predates the kits* | `leonardo` |
| LattePanda IOTA | RP2040, 3.3 V | [`hardware/lattepanda-iota/`](../hardware/lattepanda-iota/) | `iota_rp2040` |
---
## 1. SSH by key `[verified]`
Everything after this is driven over ssh from the dev box, and `deploy.sh` runs
several ssh commands per invocation, so password auth is not workable.
```bash
# on the dev box
ssh-copy-id -i ~/.ssh/id_ed25519_ggs_pedro.pub ggs@<tower-ip>
```
Then give the host a block in `~/.ssh/config`, so every tool picks the right key
without being told:
```
Host 10.11.12.121
User ggs
IdentityFile ~/.ssh/id_ed25519_ggs_pedro
```
Verify with `ssh ggs@<tower-ip> hostname` - it must not prompt.
> **`ssh-copy-id` needs a terminal.** Run it in a real shell, not through a tool
> that gives it no tty: without one, ssh looks for a GUI `ssh-askpass`, and on a
> machine that has none it fails with `ssh_askpass: exec(...): No such file or
> directory` and then `Permission denied` - which looks like a rejected key
> rather than a prompt that never appeared.
## 2. Survey the machine before changing it `[verified]`
The setup kit can describe the machine without touching it, and this is the
cheapest moment to notice that the tower is not what you assumed:
```bash
rsync -a hardware/ ggs@<tower-ip>:~/projects/fwt_2a/software/hardware/
ssh ggs@<tower-ip> '~/projects/fwt_2a/software/hardware/lattepanda-iota/install.sh --probe'
```
```
board : lattepanda-iota
os : Ubuntu 26.04.1 LTS (7.0.0-31-generic)
motor MCU : /dev/serial/by-id/usb-MicroPython_Board_in_FS_mode_df61a863dfa02932-if00
camera DEV_ id : NOT FOUND (needs the Vimba X SDK installed)
alvium on usb : 1 device(s)
usbfs_memory_mb : 16 (want >= 1000)
camera lpm_permit: u1_u2 (want 0)
```
Read every line. On tower 121 this pass found that the MCU was still running the
MicroPython image it shipped with, and that both USB preconditions were at their
(wrong) defaults.
**Check what is already in `~/projects/`.** A machine cloned from another tower,
or one that has been used for development, can arrive with a `config.ini` that
belongs to a different rig. Tower 121 had a byte-identical copy of the dev box's
`config.ini` - `tower_name = dev`, broker `127.0.0.1` - which is worse than no
config at all, because **none of those values trip the placeholder guard in
`deploy.sh --run`**. That guard only catches `ExampleTower`, `CHANGE_ME` and
`DEV_0000000000`.
```bash
ssh ggs@<tower-ip> 'grep -E "tower_name|zkms_server_ip|^id" ~/projects/fwt_2a/software/config.ini'
```
## 3. Host preconditions and build dependencies `[verified]`
Both come from the board's setup kit, in one step, on the device:
```bash
ssh ggs@<tower-ip>
cd ~/projects/fwt_2a/software/hardware/lattepanda-iota
sudo ./install.sh --deps --preconditions
```
`--deps` installs [`packages.txt`](../hardware/lattepanda-iota/packages.txt): the
toolchain, OpenCV, Boost, libjxl, tmux, and the Python bits PlatformIO needs.
`--preconditions` installs two files and applies them immediately:
| File | Why |
|---|---|
| `/etc/udev/rules.d/99-fwt-alvium-usb.rules` | Disables USB3 hardware LPM on the camera's port, which the host otherwise re-enables on every enumeration. With U1/U2 on, the camera logs link errors and frame delivery stalls (known-issues #19) |
| `/etc/tmpfiles.d/10-fwt-usbfs.conf` | Raises `usbfs_memory_mb` from 16 to 1000 at every boot. A single full-resolution frame does not fit in 16 MB |
Both are settings that **revert on reboot** if nobody persists them, which is the
whole of known-issue #19. The script re-reads them after applying and says so:
```
>> usbfs_memory_mb = 1000
>> camera USB3 LPM disabled (2-1)
```
If it instead warns that a value did not take, stop and find out why - a camera
on a host with these wrong looks like a flaky camera.
Confirmed on tower 121 (2026-09-15): both went from wrong to right in one run,
`usbfs_memory_mb` 16 -> 1000 and `lpm_permit` `u1_u2` -> `0`. Re-check after the
first reboot, since surviving one is the entire point.
> **Neither step can be driven from a tool without a terminal.** `sudo` needs a
> tty to prompt, so this runs from a real terminal window:
>
> ```bash
> ssh -t ggs@<tower-ip> 'cd ~/projects/fwt_2a/software/hardware/lattepanda-iota && sudo ./install.sh --deps --preconditions'
> ```
> **The Delta fleet does not have these applied.** Checked on `10.11.12.111`
> 2026-09-15: `usbfs_memory_mb` is 16 and the kernel cmdline is stock. The
> preconditions were documented in known-issues but never persisted anywhere.
## 4. Vimba X SDK `[verified]`
Proprietary and login-walled, so it is staged from a machine that already has it
rather than downloaded on the tower:
```bash
# dev box
rsync -az ~/Programs/VimbaX_2025-3 ggs@<tower-ip>:~/
# tower
sudo ./install.sh --vimba ~ggs/VimbaX_2025-3
```
That copies it to `/opt/VimbaX_<version>`, points `/opt/VimbaX` at it, runs the
SDK's `Install_GenTL_Path.sh` (which registers `GENICAM_GENTL64_PATH` in
`/etc/profile.d` and writes the `1ab2` udev rule), registers `api/lib` with the
dynamic loader, and then asks the SDK to list cameras as a check.
**The SDK asks for a reboot before the transport layers are usable.** If it
finds nothing, reboot and try again before suspecting the camera. On tower 121
no reboot was needed - the camera answered immediately.
Two things about that check, both found the hard way here:
- **The binary is `ListCameras_VmbC`**, in `/opt/VimbaX/bin/`. There is no plain
`ListCameras`.
- **Run it with `GENICAM_GENTL64_PATH` set**, or it fails with `Could not start
system. Error code: -16` on a perfectly good installation - that is "no
transport layer found", not "no camera":
```bash
GENICAM_GENTL64_PATH=/opt/VimbaX/cti /opt/VimbaX/bin/ListCameras_VmbC
```
> **The SDK ships a camera simulator, and it is on by default.** On tower 121
> `ListCameras_VmbC` reported **four** cameras: the real Alvium plus three
> synthetic ones from `VimbaCameraSimulatorTL.cti`, with ids like `DEV_Cam1`. A
> tower configured with one of those would open its "camera" happily and capture
> synthetic images forever. Take the id only from a block whose transport layer
> path is `VimbaUSBTL` or `VimbaGigETL` - which is what `install.sh --probe`
> does, after this bit me.
> Non-interactive ssh shells do not source `/etc/profile.d`, which is why
> `deploy.sh` resolves `GENICAM_GENTL64_PATH` itself before launching the app.
> Getting this wrong shows up as `VmbStartup()` failing with *"Could not start
> Vimba X API"* even though the viewer works when you are logged in.
## 5. `hardware.ini` - what this machine IS `[verified]`
```bash
sudo ./install.sh --hardware-ini --axes yaw --imu none --env none --cameras rgb
```
This writes `/etc/fire_gimbal_control/hardware.ini`, **outside** the directory
`deploy.sh` rsyncs, so no deploy can overwrite or delete it.
The probed half - board model, firmware env, the MCU's stable `by-id` path, the
camera's `DEV_` id - the script fills in. The declared half is the flags, and
they are not a formality: the program degrades gracefully around missing
hardware, so **a part that was never fitted and a part that has failed look
identical** unless the file says which was expected. Declare what was assembled.
Check the result, especially the serial path:
```bash
ssh ggs@<tower-ip> 'cat /etc/fire_gimbal_control/hardware.ini'
```
Then prove the program finds it **with nothing in the environment** - no
`FGC_HARDWARE`, no `--hardware` - because that is how the tower starts itself:
```
[INFO ] hardware: /etc/fire_gimbal_control/hardware.ini board lattepanda-iota axes yaw
```
> **Do not leave it anywhere under `/tmp`.** That is tempting during bring-up
> because it needs no root, and it works right up until the first reboot, which
> clears `/tmp` and leaves the program with `Config error: Camera.rgb.id is
> required` - a message that points at the camera rather than at the file that
> disappeared. Verified on tower 121 by rebooting it.
### The drivetrain block
**`install.sh` now writes this block**, one `[Motor.<axis>]` per declared axis —
it has to, because the firmware refuses to move until it has been told these
(see the config handshake in [configuration.md](configuration.md)). What it still
cannot do is *probe* them: no reading of a board tells you its sense resistor.
So pass what you know and it writes the values; omit anything and it writes a
commented `; TODO` line naming what to measure:
```bash
./install.sh --stdout --axes yaw \
--r-sense 0.075 --run-current 756 --homing-current 200 \
--travel-deg 340 --steps-per-rev 1210778 --sw-mode 0
```
A `TODO` line is **commented, not a placeholder value**, and deliberately: these
keys are numeric, and a non-numeric value makes the program refuse to start with
a message about a config key. Left absent, it starts and names exactly which keys
that axis is missing and that it will not move — the useful failure.
The block it writes looks like this:
```ini
[Motor.yaw]
r_sense_ohm = 0.075 ; the resistor fitted to THIS driver board
run_current_ma = 756 ; normal motion
homing_current_ma = 200 ; the endstop search only
travel_deg = 340 ; mechanical angle between the hard stops
```
**`homing_current_ma` is a safety setting, not a tuning one.** Homing finds a
stop by driving into it until the shaft falls behind; that only works while the
stop is stronger than the motor. A high gear ratio can shear a screw-head stop
straight past its mate - on tower 121 the axis ran 1.8 turns past the stop with
the encoder never falling behind, at full torque. Declare a reduced search
current wherever the stop cannot survive one.
Getting it wrong in either direction is visible: too strong and the stop yields
again; too weak and the motor stalls on ordinary friction mid-sweep, trips the
same latch, and records a phantom endstop. The firmware's `steps_per_rev` guard
is what catches the second case, so it has to be set from the real travel.
> **The centre lives in `config.ini`, not here.** `install.sh` cannot write it —
> `hardware.ini` is merged *over* `config.ini` and wins, so a shared key would
> silently discard whatever `gimbal heading setup` had saved. What it does instead
> is work the requirement out from `steps_per_rev` and print it as a `NOTE` in the
> block, because the default is wrong for any geared-up rig:
>
> ```
> ; NOTE: half this travel is 605389 counts, so config.ini needs
> ; [Motor] yaw_center_count = 1000000 (or anything above 605389)
> ; The 500000 default would put the negative end at -105389.
> ```
>
> The range is centre ± half the travel, so a centre below half-travel puts the
> negative end under zero. On tower 121 the inherited `500000` homed to
> `-104749..1104749`; at `1000000` it homes to `395194..1604807`. A warning names
> it after every home if it is still too small.
**`travel_deg` is a nameplate fact**, and the host divides the measured homing
travel by it to derive `[Motor] <axis>_counts_per_deg` into `config.ini`. A rough
angle gives a proportionally rough degree scale - 1 % out here is 1 % out on every
bearing - until `gimbal heading setup` supersedes it with a landmark fit.
The startup log reports what the registers **actually** deliver, which is not
always what was asked for:
```
yaw motor current: run 756 mA (GLOBALSCALER 80, IRUN 31), homing 189 mA (IRUN 7)
-- R_sense 0.075 ohm from hardware.ini [Motor.yaw]
```
Omit the block entirely on a tower whose endstops hold against full torque: no
`CURRENT` is sent, the firmware keeps its compile-time defaults, and nothing
changes.
> **If an axis declares a reduced homing current and the firmware has not adopted
> it, the host refuses to home** rather than driving into a stop at full torque.
> The message names the skew; the fix is to flash the firmware, not to override
> it.
> `[Serial] device` must be a `/dev/serial/by-id/` path, never `/dev/ttyACM0`.
> Tower 121 has a 5G modem that claims three `ttyUSB` nodes, and enumeration
> order is not stable across reboots.
>
> **The by-id path changes when you flash the MCU**, because the name comes from
> the running image: `usb-MicroPython_Board_in_FS_mode_*` before, a Pico/Arduino
> name after. Re-run `--probe` after step 6 and fix the file if it moved.
## 6. Motor firmware `[verified]`
The firmware is built and flashed **on the tower** - it is the machine wired to
the MCU - so PlatformIO is installed there, not on the dev box.
```bash
# tower: PlatformIO core, in its own venv under ~/.platformio. No sudo needed.
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o /tmp/get-platformio.py
python3 /tmp/get-platformio.py
~/.platformio/penv/bin/pio --version # check it actually runs
```
`[verified]` on tower 121: PlatformIO Core 6.2.0 on the system Python 3.14.4.
> The installer ends with a `FileNotFoundError` traceback out of
> `multiprocessing/util.py` about a missing `/tmp/.piocore-installer-*` path.
> It is a temp-directory cleanup finalizer running after the work is done -
> cosmetic. Judge the install by whether `pio --version` answers.
`deploy.sh` looks for `pio` in the usual places, but a non-interactive ssh shell
does not have `~/.platformio/penv/bin` on PATH, so name it explicitly:
```ini
REMOTE_PIO=/home/ggs/.platformio/penv/bin/pio
```
Then, from the dev box, in `firmware/`:
```ini
# .deploy.env
REMOTE_HOST=ggs@10.11.12.121
REMOTE_DIR=/home/ggs/projects/fwt_2a/firmware
PIO_ENV=iota_rp2040 # leonardo on a Delta
SERIAL_PORT=/dev/serial/by-id/usb-...
```
```bash
./deploy.sh --compile-only # build on the tower, flash nothing
./deploy.sh # build + upload
./deploy.sh --monitor # watch it boot
```
**Confirm the pin map before flashing a board that is wired to motors.** The map
lives in `src/platform_iota.h` and a chip-select on the wrong pin writes into a
driver that was never meant to hear it. The numbers there are the loom as built
on tower 121, which is **not** the map proposed in the porting plan.
### Getting a factory board to accept its first flash
`[verified]` on tower 121, and this is the step that will bite you. These boards
ship running MicroPython, and **the normal upload does not work against it**:
```
Forcing reset using 1200bps open/close on port /dev/ttyACM0
No new RPxxxx device found yet, waiting.. (x20, then it gives up)
```
| What | Factory board (MicroPython) | Our firmware |
|---|---|---|
| 1200-baud touch, i.e. a plain upload | no reset at all | resets, but **reboots into the application** |
| `picotool reboot -f -u` | refused: no reset interface | - |
| MicroPython REPL | **works** | n/a |
So drop into MicroPython's own REPL and tell it to enter the bootloader. Write
this to a file and run it with `~/.platformio/penv/bin/python` (it has pyserial):
```python
import serial, time
s = serial.Serial("/dev/serial/by-id/usb-MicroPython_Board_in_FS_mode_XXXX-if00", 115200, timeout=1)
s.write(b"\x03\x03"); time.sleep(0.3) # Ctrl-C: interrupt whatever is running
s.write(b"import machine\r\n"); time.sleep(0.3)
s.write(b"machine.bootloader()\r\n") # the port drops here - that is success
```
```bash
lsusb | grep 2e8a # expect: 2e8a:0003 Raspberry Pi RP2 Boot
```
Then run `./deploy.sh` and it flashes. A physical BOOTSEL press does the same
job if you are standing at the board.
**Every board needs exactly one physical BOOTSEL press in its life** - the one
that installs the first image carrying the `BOOTSEL` command (below). After
that, updates are remote.
**`sudo ./install.sh --preconditions` must have run first**, or picotool finds
the board and refuses it:
```
RP2040 device at bus 1, address 4 appears to be in BOOTSEL mode, but picotool
was unable to connect. Maybe try 'sudo' or check your permissions.
```
### Re-flashing later: the `BOOTSEL` command
`[verified]` on tower 121. Neither documented remote path works on this board -
the 1200-baud touch resets the firmware but returns it to the application, and
`picotool reboot -f -u` answers `Unable to locate reset interface on the
device`. So the firmware carries a command that does it deliberately:
```bash
# over the serial port, or from the app: gimbal raw "BOOTSEL"
BOOTSEL -> OK entering bootloader - reflash to return
lsusb | grep 2e8a # 2e8a:0003 Raspberry Pi RP2 Boot
./deploy.sh # flashes and reboots into the new firmware
```
The whole loop was run end to end from the dev box with nobody at the tower.
`BOOTSEL` disables every axis first, since the bootrom does not run the control
loop and unsupervised energised drivers would hold torque.
> **It takes the gimbal out of service and the firmware cannot undo it** - once
> in the bootrom there is no command loop left to change its mind. Recovery is
> `deploy.sh`, or a power cycle: entering the bootrom erases nothing, so the
> existing image is still in flash. Anything that can write to the serial port
> can send it, including `gimbal raw`. Near misses (`BOOT`, `BOOTSE`) are
> rejected as unknown commands.
### The identity changes when you flash
| | by-id path | USB |
|---|---|---|
| before | `usb-MicroPython_Board_in_FS_mode_<serial>-if00` | `2e8a:0005` |
| after | `usb-Raspberry_Pi_Pico_<SERIAL>-if00` | `2e8a:000a` |
So generate `hardware.ini` (step 5) **after** flashing, or re-run
`install.sh --hardware-ini --force` to pick up the new path.
### Verify the flash
Read the boot banner over the serial port:
```
BOOT build=aab1925-dirty
IOIN Y=0x3000001E version=0x30
ST Y:R,500000,18,80084000,0,8,8,Sd
```
`version=0x30` is the TMC5160 answering over SPI - the single most useful line
here, because it proves the pin map, the 3.3 V logic level and the driver board
all at once. `BOOT build=` must match the commit you flashed. One `Y` line and
no `P` line is a yaw-only build.
> **Flashing an RP2040 remotely is not the same as a Leonardo.** The documented
> flow is *hold BOOTSEL, tap RST, drag a `.uf2`* - a person at the board. The
> two remote paths (the 1200-baud touch that the Arduino tooling already uses,
> and `picotool reboot -f -u`) both need the **running** image to present a USB
> CDC port. That is why `setup()` brings `Serial` up before any hardware init:
> an image that hangs before USB comes up cannot be reflashed without a site
> visit, while an interrupted flash recovers by itself - the bootrom falls back
> to mass storage when flash holds no valid image.
## 7. Deploy the software `[verified]`
On the dev box, in `software/`:
```ini
# .deploy.env
REMOTE_HOST=ggs@10.11.12.121
REMOTE_DIR=/home/ggs/projects/fwt_2a/software
CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON"
RUN_ARGS="--start"
```
```bash
./deploy.sh --check-deps # must print "remote deps OK"
./deploy.sh # rsync + remote cmake + build
```
The first deploy seeds `config.ini` from `config/config.example.ini` **only if
the device has none** - see the warning in step 2 about a machine that arrived
with somebody else's config.
`[verified]` on tower 121: builds clean on GCC 15 / Ubuntu 26.04 with a single
`-Wformat-truncation` warning in `utcStampNow()`, which is a false positive (the
compiler cannot prove `tm_year` is four digits; the real output is 17 bytes into
a 32-byte buffer).
## 8. Camera profiles `[verified]`
**Without a profile the program does not start** - `no camera profiles (*.xml)
in config/camera_profiles/rgb` is a startup error, by design. A fresh tower has
an empty profile directory, so this step is not optional.
The documented way to make one is the VimbaX Viewer, which is a GUI - awkward on
a headless tower and impossible unattended. `scripts/export_camera_profile.py`
does the same job through the SDK's Python API:
```bash
# once per tower: the SDK ships the wheel
python3 -m venv ~/.venvs/vmbpy
~/.venvs/vmbpy/bin/pip install /opt/VimbaX/api/python/vmbpy-*.whl
cd ~/projects/fwt_2a/software
export GENICAM_GENTL64_PATH=/opt/VimbaX/cti
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile standard --dry-run
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile standard
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile night
```
It writes each recipe value to the real camera **in dependency order**, lets the
camera validate it, and exports what was actually accepted. That preserves the
property the Viewer workflow exists to provide: GenICam validity is stateful, so
a hand-written XML can be individually plausible and collectively wrong. Here
the camera is the judge, and anything it refused is printed rather than assumed.
Read the summary line it prints - it is the number that decides whether the
profile can be delivered at all:
```
result : 2664x1520 RGB8 12.1 MB/frame 4.95 fps = 60 MB/s
```
### Profiles do not transfer between cameras
`[verified]` the hard way. The fleet runs several Alvium generations and their
feature vocabularies differ, so copying another tower's XML does not work:
| | Delta tower (1800 U-1240c) | IOTA tower (1800 U-1620c) |
|---|---|---|
| `SensorBitDepth` | `Bpp8` | **no `Bpp8`** - only `Adaptive` / `Bpp12` |
| `IntensityAutoPrecedence` | `Minimizeexposuretime` / `Minimizenoise` | **`MinimizeBlur` / `MinimizeNoise`** |
| Sensor | 12 MP | 16 MP, 5328x3040 |
### Binning is not optional, and the frame-rate ceiling is not what it looks like
Unbinned RGB8 on the 16 MP sensor is a **48.6 MB frame** - over the ~20 MB
ceiling in known-issues #19. 2x2 binning brings it to 12.1 MB.
The frame rate is then capped by `DeviceLinkThroughputLimit`, but the limit is
accounted against the **unbinned** payload, not the binned frame that crosses
the wire. Measured on tower 121:
| `DeviceLinkThroughputLimit` | max `AcquisitionFrameRate` | actual USB traffic |
|---|---|---|
| 250 MB/s (the fleet's value) | 4.95 fps | ~60 MB/s |
| 450 MB/s (this camera's max) | 8.79 fps | ~106 MB/s |
48.6 MB x 4.95 = 240 MB/s, which is the 250 limit - so the camera is budgeting
for data that binning discards. **The recipe ships the conservative 250 MB/s**,
giving ~5 fps rather than the Delta's 10, because the USB stalls behind
known-issues #19 are a host-side finding that has not been re-measured on the
N150 yet (porting plan phase 5). Raising it is a one-line change in the recipe
once that measurement exists.
> **Export `standard` after `night`, not the other way round, or use the current
> script.** A refused write is not a no-op: the first version of this script
> asked for 10 fps, the camera refused, and the 1 fps left over from the night
> profile stayed - producing a "standard" profile that ran at night speed. The
> script now clamps an out-of-range numeric value to the camera's limit and says
> `~ ... (clamped)`.
## 9. `config.ini` - what this tower DOES `[partial]`
Device-local, never overwritten by a deploy. Edit it on the tower:
```ini
[General]
tower_name = <this tower> ; drives every MQTT topic
[Network]
zkms_server_ip = <broker>
```
Credentials belong in `/etc/fire_gimbal_control/credentials.env`, not here.
Anything `hardware.ini` now owns - `[Serial] device`, `[IMU]`, `[Env]`, the
camera ids - should be **deleted** from `config.ini` rather than left to rot:
the program warns at startup about every key set in both.
> **`[Camera.<name>] id` is required even when the camera is disabled.** Config
> validation runs before any of the feature flags are consulted, so a tower with
> no camera fitted yet cannot start without one - and the obvious dummy,
> `DEV_0000000000`, is what `deploy.sh`'s placeholder guard refuses. On a tower
> that has its camera, the id lives in `hardware.ini` and this never comes up.
`[Features] enable_imu` and `enable_env` matter more than they look. Declaring
`imu = none` in `hardware.ini` does **not** yet switch them off - deriving them
is section 12 of the config plan and is not implemented. On tower 121 the first
clean-looking run did this:
```
[INFO ] MTi IMU started on /dev/ttyS0 @ 115200
[ERROR] I2C: failed to open /dev/i2c-2
[ERROR] SHT41: failed to open I2C bus; sensor disabled
```
It "started" an IMU on a tower that has none - `/dev/ttyS0` exists and accepts a
serial open whether or not anything is attached to it. Set both flags to `false`
by hand until reconciliation lands.
## 10. First run: bring the subsystems up one at a time `[partial]`
Do **not** start a new tower with everything switched on. Each phase below adds
exactly one subsystem, so that a failure names its own cause instead of arriving
as a wall of unrelated errors. It also keeps the motors - the only part that can
break itself - until you have proved the boring parts work.
Each phase says what good looks like and what noise to expect.
### Phase 0 - the host alone, no hardware `[verified]`
```bash
./build/fire_gimbal_control --mock-serial --mock-camera --no-mqtt --no-tui
```
Proves config resolution, `hardware.ini` layering, the single-instance lock, the
scan grid and the output directory - with nothing physical involved. Neither a
camera nor its profile XMLs need exist:
```
[INFO ] camera profiles not loaded: no real camera in this run
```
`[Features] enable_camera = false` does the same thing from the config file, for
a tower that has no camera fitted yet, and says so rather than pretending:
```
[WARN ] camera disabled ([Features] enable_camera = false); using a mock source
- no images come from hardware in this run
```
```
[INFO ] hardware: /etc/fire_gimbal_control/hardware.ini board lattepanda-iota axes yaw
[INFO ] Loaded config: config.ini
[INFO ] scan grid: 15 waypoints
```
Any `config.ini still sets X, which hardware.ini overrides` warning belongs here
and should be cleared before moving on (step 9).
### Phase 1 - motors `[partial]`
Real serial link, nothing else:
```bash
./build/fire_gimbal_control --mock-camera --no-mqtt --no-tui
```
What proves the link is a **live `DUMP` from the board**, not just an open port:
```
[INFO ] Serial controller started on /dev/serial/by-id/usb-Raspberry_Pi_Pico_...
DUMP BEGIN build=aab1925-dirty uptime=7925212 mcusr=0x3 free_ram=252712
DUMP Y state=1 ... speed=3000 center=500000 has_encoder=1
```
Check `build=` matches the firmware you flashed and `speed=` matches the
drivetrain values you intended. Then, and only then, move the thing:
```
gimbal home # the endstop-finding sequence - see the warning below
gimbal diag y # firmware self-test, reports PASS/FAIL
gimbal move 10,0 # a small deliberate move
```
**Expected noise in this phase, neither of which is a fault:**
| Message | Why |
|---|---|
| `Motor controller: ERR axis not present` | The host configures both axes at startup; a yaw-only firmware refuses the pitch half. Harmless until presence reconciliation lands (config plan section 10) |
| `encoder tracking error on yaw: 500366 counts (limit 400)` | Before homing, the firmware presets `XACTUAL` to the centre count while the encoder sits at its own zero, so they disagree by the whole range. Homing resyncs them. **If it persists after a successful home, that is real** |
> **On an unfamiliar drivetrain, homing is the dangerous step** - it finds the
> endstop by driving into it. Start from a deliberately slow `homing_speed` and
> walk it up, watching the free-running encoder gap that `ENC_DEVIATION_LIMIT` is
> measured against. Be at the rig with a hand on the power for the first one.
### Phase 2 - camera `[verified]`
Add the camera, still no broker:
```bash
./build/fire_gimbal_control --no-mqtt --no-tui
```
```
[INFO ] Opened camera 'rgb' DEV_1AB22C0B7D4E (1800 U-1620c)
[INFO ] camera profile 'standard' applied from .../standard.xml (17206 settings)
```
Then `start`, and confirm `.jxl` files appear under the configured output dir.
> **A camera-free phase needs no profile XMLs** - but this only became true on
> 2026-09-16. Before that, `[Features] enable_camera` was parsed and never used,
> and the profile directory was validated even for a mock camera, so a tower
> whose camera had not been configured yet could not start at all. If you are
> running an older binary, export the profiles (step 8) first or it will refuse
> with `no camera profiles (*.xml)`.
### Phase 3 - the broker
Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker.
Without one, the app says so and carries on:
```
[ERROR] MQTT connect failed (tcp://10.11.12.121:1883): TCP/TLS connect failure
[WARN ] Control channel not connected; continuing in degraded mode
```
**Degraded mode is not a failure to notice later** - the tower captures images
and reports to nobody. Confirm topics arrive at the broker before calling the
phase done.
Each tower runs its own RabbitMQ, in Docker, with the `rabbitmq_mqtt` and
`rabbitmq_shovel` plugins - the shovel is what forwards this tower's messages to
the ground station. That lives in `~/zkms-client/`, outside this repository.
### Phase 4 - IMU and ambient sensor
Only if fitted. `hardware.ini` should already say so, and `[Features]
enable_imu` / `enable_env` have to agree with it (see step 9 - declaring
`imu = none` does not yet switch the flag off by itself).
### Phase 5 - unattended
Only once every phase above is green:
```bash
scripts/fgc start --start # detached, inside tmux
scripts/fgc status # running? since when? which PID?
```
Close the ssh session, reconnect, and check it is still running. Then reboot the
tower and confirm the USB preconditions survived (`install.sh --probe`) and the
app comes back.
### Calibration
Per tower, and **cannot be inherited** - least of all from a tower with a
different gear ratio, where `yaw_counts_per_deg` is derived from a
`steps_per_rev` that no longer applies:
```
gimbal heading setup # ties yaw to true north by sighting landmarks; no IMU needed
gimbal pitch setup # needs an IMU; skip on a rig without one
```
## 10b. Bringing up the config handshake on an existing tower
This is the one upgrade that changes what an **unconfigured** tower does: the new
firmware boots into `NEEDCFG` and refuses `HOME` / `MOVE` / `ENABLE` until the
host has answered its `NEED` lines. Flashing it onto a tower whose `hardware.ini`
predates the drivetrain keys leaves the gimbal inert — working, talking, and not
turning.
So the order matters. **Host first, firmware second.** Each step below either
changes nothing or is reversible, and the step that could strand the tower comes
after the one that prevents it.
### 0. Record what you are rolling back to
```bash
gimbal dump # the running firmware's build id and register state
cat /etc/fire_gimbal_control/hardware.ini
git -C ~/projects/fwt_2a/firmware rev-parse --short HEAD
```
Keep the dump. It is the only record of what the old firmware was holding, and
the comparison that tells you whether the new one adopted the same values.
### 1. Update `hardware.ini` — nothing else, and no restart yet
Add the `[Motor.<axis>]` block (section 5). The five keys the new firmware will
not run without are `steps_per_rev`, `enc_deviation`, `sw_mode`, plus the two
speeds; `install.sh --stdout` generates the block if you would rather not type it.
Doing this **before** flashing is what keeps the window at zero: the host is ready
to answer the moment the firmware starts asking.
### 2. Deploy the new host against the OLD firmware
A real test, and it risks nothing. The old firmware never sends `NEED`, so the
host must behave exactly as it did before — which is also the guarantee the whole
Delta fleet depends on.
```bash
scripts/fgc start --start
scripts/fgc peek
test hardware motor
```
Expect: no handshake lines at all, and the motor leaf reporting a **version skew**
rather than a mismatch —
```
; yaw_current: yaw: firmware echoes only part of the current (2 of 3 fields
absent), so it cannot be confirmed -- it predates the full CURRENT echo. Re-flash it
```
If instead you see wrong *values*, stop: something in `hardware.ini` disagrees
with what the tower is really running, and flashing will not fix it.
### 3. Flash
```bash
cd firmware && ./deploy.sh # uploads; --compile-only if you want a dry run first
```
Watch the banner. A `NEED` line per axis should follow it:
```
BOOT build=<rev>
NEED Y steps_per_rev global_scaler run_irun homing_irun center_count soft_margin ...
```
**No `NEED` line means the axis is not in `NEEDCFG`** — either the flash did not
take, or the image is older than you think. Check the build id before going on.
### 4. Confirm the handshake completed
The host answers within a control-loop tick. Expect:
```
firmware config: answered 11 keys for axis Y
```
Then prove it landed rather than trusting the log:
```
gimbal dump # state should be 1 (RESET), NOT 5 (NEEDCFG)
# cfg_rx should be the full required mask
test hardware motor # both metrics PASS
```
`test hardware motor` is the real gate: it compares every declared value against
what the firmware echoes, so a `SET` that was lost on the wire shows up here and
nowhere else.
### 5. Prove the refusal actually refuses
Worth doing once per firmware version, because a guard that silently stopped
working looks exactly like a guard that is working.
On a **bench** board, not a mast: power-cycle it without the host running, then
send `HOME` over a serial monitor. Expect `ERR need config`. Send `RESET` and try
again — still `ERR need config`, because a RESET is deliberately not a way out.
`USE DEFAULTS` is, and should then let `HOME` through.
### 6. Only then, move the axis
```
gimbal home # at the reduced homing current
```
Check against the recorded figures: travel ~1,210,778 counts, repeatable to
~0.3 %, and the derived `counts_per_deg` near 3552.87. A travel that disagrees by
more than a few percent means the axis homed against something that was not the
endstop.
### If it goes wrong
The board is recoverable over the network as long as USB CDC comes up:
`BOOTSEL` over the serial port, then `./deploy.sh` with the previous firmware
checked out. Only an image that wedges *before* `Serial.begin()` needs a physical
button press — which is why `Serial.begin()` is the first thing `setup()` does.
`USE DEFAULTS` is the in-place escape: it puts every axis on the compile-time
table and lets the tower move while you work out what `hardware.ini` is missing.
## 11. Verification checklist
| Check | How | Tower 121 |
|---|---|---|
| Key-only ssh | `ssh ggs@<tower> hostname`, no prompt | ✅ |
| Preconditions survive a reboot | reboot, then `install.sh --probe`: `usbfs_memory_mb 1000`, `lpm_permit 0` | ✅ confirmed across a real reboot |
| `hardware.ini` found unaided | run with no `FGC_HARDWARE`: startup names `/etc/fire_gimbal_control/hardware.ini` | ✅ |
| Camera enumerates | `ListCameras_VmbC` reports the `DEV_` id in `hardware.ini` | ✅ `DEV_1AB22C0B7D4E` |
| Camera profile applies | startup logs `profile 'standard' applied ... (N settings)` | ✅ 17206 settings |
| Firmware identity | `gimbal dump` reports the `GIT_REV` you flashed | ✅ `build=aab1925-dirty` |
| Driver answers over SPI | boot banner shows `IOIN Y=0x… version=0x30` | ✅ |
| Remote re-flash | `BOOTSEL`, then `./deploy.sh` - no hands on the board | ✅ |
| Declared hardware matches | startup logs no presence warnings | ✅ no shadowed-key warnings |
| Homing repeatable | `gimbal home` several times, endstop counts within `home_spread_max_counts` | ✅ three homes within 0.27 % (1,210,778 / 1,208,358 / 1,207,514 counts) |
| Endstop survives homing | inspect the stop after a home; the axis must stall against it, not push through | ✅ at 189 mA, after failing at full torque |
| Degree scale derived | startup logs `counts_per_deg` agreeing with the homed travel | ✅ 3552.87, saved with `;prev` history |
| Images land | `.jxl` files appear under the configured output dir | ❌ needs a capture run |
| Survives a disconnect | `scripts/fgc start`, close ssh, reconnect, `scripts/fgc status` | ❌ not attempted |
| Reports to a broker | topics arrive at the configured broker | ❌ no broker on this tower yet |

29
hardware/README.md Normal file
View File

@ -0,0 +1,29 @@
# Board setup kits
One directory per board the fleet runs on. A kit holds everything that turns a
freshly imaged machine into a tower host, and nothing that differs between two
towers built on the same board:
```
<board>/
packages.txt apt packages the build and the runtime need
preconditions/ udev rules, tmpfiles.d - the settings that must survive a reboot
install.sh apply the preconditions, install the packages, probe the
machine and emit a starting hardware.ini
NOTES.md quirks found on this board, and when they were last verified
```
`install.sh` fills in what a machine can be *asked* - board model, the motor
MCU's by-id path, the camera's `DEV_` id - and leaves to a human what no probe
can distinguish from a fault: which axes the gimbal has, whether an IMU or an
ambient sensor was fitted, and the drivetrain figures. See
[docs/hardware-config-plan.md](../docs/hardware-config-plan.md) §8 and §10.
The file it writes is `/etc/fire_gimbal_control/hardware.ini`, deliberately
outside the directory `deploy.sh` rsyncs: a deploy cannot overwrite what it never
touches.
| Kit | Board | State |
|-----|-------|-------|
| [lattepanda-iota/](lattepanda-iota/) | LattePanda IOTA (N150, RP2040 MCU) | in use on FWT tower 10.11.12.121 |
| lattepanda-3-delta/ | LattePanda 3 Delta (N5105, ATmega32U4 MCU) | not written - the Delta fleet predates the kits |

View File

@ -0,0 +1,519 @@
#!/usr/bin/env bash
#
# install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host.
#
# Run it ON the device:
# sudo ./install.sh # everything: deps, preconditions, hardware.ini
# sudo ./install.sh --deps # apt packages only
# sudo ./install.sh --preconditions # udev + tmpfiles only, applied immediately
# ./install.sh --probe # print what this machine looks like, change nothing
# sudo ./install.sh --vimba ~/VimbaX_2025-3 # install a staged Vimba X SDK
# ./install.sh --stdout # print the hardware.ini it would write
#
# What it can work out for itself: the board, the motor MCU's stable by-id path,
# the camera's DEV_ id, whether the USB preconditions took. What it cannot, and
# takes from flags, is what was ASSEMBLED - a missing pitch axis and a broken
# pitch axis look identical to a probe:
#
# --axes yaw (default: yaw,pitch) which axes the gimbal has
# --imu none (default: none) IMU model, or none
# --env none (default: none) ambient sensor model, or none
# --cameras rgb (default: rgb) expected [Camera.<name>] blocks
#
# The drivetrain, per axis. The firmware REFUSES TO MOVE until it has been told
# these (docs/configuration.md, the config handshake), so an axis left undeclared
# is an axis that will sit in NEEDCFG. Pass what you know; what you omit is
# written as a commented line naming what has to be measured:
#
# --r-sense 0.075 sense resistor on that axis's TMC board (ohms)
# --run-current 756 coil current for normal motion (mA RMS)
# --homing-current 200 coil current while hunting an endstop (mA RMS)
# --travel-deg 340 mechanical angle between the hard stops
# --steps-per-rev 1210778 measured full travel, in counts
# --sw-mode 0 TMC SW_MODE: 0 = no switches (StallGuard homing)
#
# These take ONE value, applied to the first axis in --axes. A second axis gets a
# commented template: nothing here can be probed, and inventing a sense resistor
# is how a driver gets cooked.
#
# Other flags:
# --force overwrite an existing hardware.ini (backs it up first)
# --no-apt-update skip 'apt-get update'
#
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ETC_DIR=/etc/fire_gimbal_control
HW_FILE="$ETC_DIR/hardware.ini"
AXES="yaw,pitch"
IMU="none"
ENV_SENSOR="none"
CAMERAS="rgb"
# Empty means "not known at install time" -> written commented out, never as a
# sentinel: these are numeric keys, and a non-numeric value makes the program
# refuse to start rather than report the axis as undeclared.
R_SENSE="" RUN_CURRENT="" HOMING_CURRENT="" TRAVEL_DEG="" STEPS_PER_REV="" SW_MODE=""
DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1
VIMBA_SRC=""
EXPLICIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--deps) DO_DEPS=1; EXPLICIT=1 ;;
--preconditions) DO_PRE=1; EXPLICIT=1 ;;
--hardware-ini) DO_HW=1; EXPLICIT=1 ;;
--probe) PROBE_ONLY=1; EXPLICIT=1 ;;
--stdout) TO_STDOUT=1; DO_HW=1; EXPLICIT=1 ;;
--axes) AXES="$2"; shift ;;
--imu) IMU="$2"; shift ;;
--env) ENV_SENSOR="$2"; shift ;;
--cameras) CAMERAS="$2"; shift ;;
--r-sense) R_SENSE="$2"; shift ;;
--run-current) RUN_CURRENT="$2"; shift ;;
--homing-current) HOMING_CURRENT="$2"; shift ;;
--travel-deg) TRAVEL_DEG="$2"; shift ;;
--steps-per-rev) STEPS_PER_REV="$2"; shift ;;
--sw-mode) SW_MODE="$2"; shift ;;
--vimba) VIMBA_SRC="$2"; DO_VIMBA=1; EXPLICIT=1; shift ;;
--force) FORCE=1 ;;
--no-apt-update) APT_UPDATE=0 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
shift
done
if [[ $EXPLICIT -eq 0 ]]; then DO_DEPS=1; DO_PRE=1; DO_HW=1; fi
say() { printf '>> %s\n' "$*"; }
warn() { printf '!! %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
need_root() { [[ $EUID -eq 0 ]] || die "$1 needs root - re-run with sudo"; }
# ---------------------------------------------------------------- probing ---
# Board, from DMI. A kit applied to the wrong board is a real mistake, not a
# nuisance: the pin map and the device paths below are IOTA-specific.
probe_board() {
local dmi=/sys/class/dmi/id/product_name
[[ -r $dmi ]] || { echo "unknown"; return; }
case "$(tr -d '\0' < "$dmi")" in
*IOTA*|*Iota*|*iota*) echo "lattepanda-iota" ;;
*"3 Delta"*) echo "lattepanda-3-delta" ;;
*Sigma*) echo "lattepanda-sigma" ;;
*) echo "unknown" ;;
esac
}
# The motor MCU's stable path. /dev/ttyACM0 is enumeration order and moves when
# any other USB serial device appears - and this tower has a 5G modem that
# claims three ttyUSB nodes. Match on what the MCU calls itself:
# RP2040 running the gimbal firmware (arduino-pico) : "Raspberry_Pi_Pico"
# RP2040 as shipped, still on MicroPython : "MicroPython_Board"
# ATmega32U4 (Delta fleet) : "Arduino_LLC_Arduino_Leonardo"
probe_mcu() {
local d found=()
for d in /dev/serial/by-id/*; do
[[ -e $d ]] || continue
case "$d" in
*Raspberry_Pi_Pico*|*MicroPython_Board*|*Arduino_LLC_Arduino*|*Pico*) found+=("$d") ;;
esac
done
(( ${#found[@]} == 1 )) && { echo "${found[0]}"; return; }
(( ${#found[@]} == 0 )) && { echo ""; return; }
printf '%s\n' "${found[@]}"
}
# The camera's DEV_ id, which is what [Camera.<name>] id takes. Only Vimba knows
# it - it is NOT the USB serial number - so this needs the SDK installed.
#
# Two traps, both hit on tower 121:
# - The example is called ListCameras_VmbC, not ListCameras.
# - The SDK ships a Camera Simulator transport layer that is enabled by
# default and reports three cameras that do not exist (DEV_Cam1..3). Ids are
# therefore taken only from a block whose transport layer is the USB or GigE
# TL. A tower configured with DEV_Cam1 would "find its camera" and capture
# synthetic images forever.
probe_camera_id() {
local tool
for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do
[[ -x $tool ]] || continue
# Non-interactive shells do not source /etc/profile.d, so the GenTL path
# has to be set here or VmbStartup() fails with error -16 (no transport
# layer) on a perfectly good installation.
local cti
for cti in "${GENICAM_GENTL64_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do
[[ -d $cti ]] && { export GENICAM_GENTL64_PATH="$cti"; break; }
done
"$tool" 2>/dev/null | awk '
/Camera ID/ { id = $NF }
/Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } }
'
return
done
echo ""
}
probe_report() {
local board mcu cam
board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)"
echo "board : $board"
echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))"
echo "motor MCU : ${mcu:-NOT FOUND}"
echo "camera DEV_ id : ${cam:-NOT FOUND (needs the Vimba X SDK installed)}"
echo "alvium on usb : $(lsusb 2>/dev/null | grep -c '1ab2:' || true) device(s)"
echo "usbfs_memory_mb : $(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?') (want >= 1000)"
local p
for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do
[[ -r $p ]] || continue
local dev; dev="$(basename "$(dirname "$(dirname "$p")")")"
local vid; vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)"
[[ $vid == 1ab2 ]] && echo "camera lpm_permit: $(cat "$p") (want 0)"
done
echo "i2c buses : $(ls /dev/i2c-* 2>/dev/null | tr '\n' ' ')"
echo "ttyS : $(ls /dev/ttyS[0-9] 2>/dev/null | tr '\n' ' ')"
}
# ------------------------------------------------------------------- deps ---
do_deps() {
need_root "--deps"
local pkgs=()
while read -r line; do
line="${line%%#*}"; line="$(echo "$line" | tr -d '[:space:]')"
[[ -n $line ]] && pkgs+=("$line")
done < "$HERE/packages.txt"
say "installing ${#pkgs[@]} packages"
[[ $APT_UPDATE -eq 1 ]] && apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}"
say "packages done"
}
# ---------------------------------------------------------- preconditions ---
do_preconditions() {
need_root "--preconditions"
install -m 0644 "$HERE/preconditions/99-fwt-alvium-usb.rules" /etc/udev/rules.d/
install -m 0644 "$HERE/preconditions/99-fwt-rp2040.rules" /etc/udev/rules.d/
install -m 0644 "$HERE/preconditions/10-fwt-usbfs.conf" /etc/tmpfiles.d/
say "installed 2 udev rules + tmpfiles.d entry"
udevadm control --reload
udevadm trigger --subsystem-match=usb --action=add
systemd-tmpfiles --create /etc/tmpfiles.d/10-fwt-usbfs.conf
say "applied to the running system"
# Report rather than assume: a rule that did not take is the whole point of
# known-issues #19, and it fails quietly.
local now; now="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')"
[[ $now == 1000 ]] && say "usbfs_memory_mb = $now" || warn "usbfs_memory_mb is $now, expected 1000"
local p any=0
for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do
[[ -r $p ]] || continue
local dev vid; dev="$(basename "$(dirname "$(dirname "$p")")")"
vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)"
[[ $vid == 1ab2 ]] || continue
any=1
[[ "$(cat "$p")" == 0 ]] && say "camera USB3 LPM disabled ($dev)" \
|| warn "camera USB3 LPM still $(cat "$p") on $dev"
done
[[ $any -eq 0 ]] && warn "no Alvium camera on the bus - LPM rule untested until one is plugged in"
# The MCU rule is what makes the firmware flashable over ssh, so check it the
# same way rather than trusting that the rule took.
local mcu_dev
mcu_dev="$(grep -l '^2e8a$' /sys/bus/usb/devices/*/idVendor 2>/dev/null | head -1)"
if [[ -n $mcu_dev ]]; then
local devnum busnum node
busnum="$(cat "$(dirname "$mcu_dev")/busnum" 2>/dev/null)"
devnum="$(cat "$(dirname "$mcu_dev")/devnum" 2>/dev/null)"
node="$(printf '/dev/bus/usb/%03d/%03d' "$busnum" "$devnum")"
if [[ -w $node ]]; then
say "MCU (2e8a) writable at $node - picotool can flash it"
else
warn "MCU (2e8a) at $node is not writable; flashing will need sudo"
ls -l "$node" >&2
fi
else
warn "no 2e8a MCU on the bus - the flashing rule is untested until one is plugged in"
fi
return 0
}
# ------------------------------------------------------------------ vimba ---
# The Vimba X SDK is proprietary and login-walled, so it cannot be fetched by a
# script. Stage it on the device first (rsync it from a machine that has it),
# then point this at the staged directory:
#
# rsync -a ~/Programs/VimbaX_2025-3 ggs@<tower>:~/ # from the dev box
# sudo ./install.sh --vimba ~ggs/VimbaX_2025-3 # on the tower
#
do_vimba() {
need_root "--vimba"
[[ -d $VIMBA_SRC ]] || die "--vimba: no such directory: $VIMBA_SRC"
[[ -x $VIMBA_SRC/cti/Install_GenTL_Path.sh ]] || die "--vimba: $VIMBA_SRC does not look like a Vimba X SDK (no cti/Install_GenTL_Path.sh)"
local name dest
name="$(basename "$VIMBA_SRC")"
dest="/opt/$name"
if [[ -d $dest && $FORCE -ne 1 ]]; then
say "$dest already present - not re-copying (--force to replace)"
else
say "installing $name to $dest"
rm -rf "$dest.new"
cp -a "$VIMBA_SRC" "$dest.new"
rm -rf "$dest"
mv "$dest.new" "$dest"
fi
# cp -a preserves the staging copy's ownership, which is the ggs user. The
# SDK is read-only system software and belongs to root.
chown -R root:root "$dest"
ln -sfn "$dest" /opt/VimbaX
say "/opt/VimbaX -> $dest"
# GENICAM_GENTL64_PATH, or VmbStartup() fails with "Could not start Vimba X
# API". The SDK's own installer writes /etc/profile.d + the 1ab2 udev rule.
"$dest/cti/Install_GenTL_Path.sh"
# Non-interactive ssh shells do not source /etc/profile.d, which is why
# deploy.sh resolves the path itself - but the loader needs the libraries
# findable regardless of how the app was started.
echo "$dest/api/lib" > /etc/ld.so.conf.d/vimbax.conf
ldconfig
say "registered $dest/api/lib with the dynamic loader"
udevadm control --reload && udevadm trigger --subsystem-match=usb --action=add
local id
id="$(probe_camera_id)"
if [[ -n $id ]]; then
say "camera found: $id"
else
warn "no camera reported by ListCameras yet"
warn "the SDK asks for a reboot before the transport layers are usable"
fi
return 0
}
# ------------------------------------------------------------ hardware.ini ---
# Emit "key = value", or a commented-out line naming what has to be measured.
#
# Commented rather than a placeholder string on purpose: these keys are numeric,
# and a non-numeric value makes the program refuse to start with a message about
# a config key. Absent, it starts and reports the axis as undeclared - naming the
# missing keys and which axis will not move, which is the useful failure.
# The centre lives in config.ini, not here - it is calibration, and hardware.ini
# is merged OVER config.ini, so a shared key would silently discard whatever
# `gimbal heading setup` had saved. All this file can do is say what the value
# has to be, which it can work out from the travel.
#
# The range is centre +/- half the travel, so a centre below half-travel puts the
# negative end under zero. The long-standing 500000 default suits the Delta's
# ~89k half-travel and does not survive a gear ratio several times higher.
center_note() {
local steps="$1" ax="$2"
[[ -z $steps ]] && return 0
local half=$(( steps / 2 ))
local want=$(( (half / 1000000 + 1) * 1000000 ))
echo "; NOTE: half this travel is $half counts, so config.ini needs"
echo "; [Motor] ${ax}_center_count = $want (or anything above $half)"
echo "; The 500000 default would put the negative end at $(( 500000 - half ))."
}
kv_or_todo() {
local key="$1" value="$2" why="$3"
if [[ -n $value ]]; then
printf '%-18s = %s\n' "$key" "$value"
return
fi
# Wrapped, and not merely for looks: ini.h sets INI_MAX_LINE to 200, and a
# longer line is SPLIT rather than truncated, so its tail becomes a bogus
# line and the whole file fails to parse. A generated file that cannot be
# read is worse than one with a gap in it.
printf '; TODO %s\n' "$key"
printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//'
}
emit_hardware_ini() {
local board mcu cam fw_env
board="$(probe_board)"
mcu="$(probe_mcu)"
cam="$(probe_camera_id)"
case "$board" in
lattepanda-iota) fw_env="iota_rp2040" ;;
lattepanda-3-delta|lattepanda-sigma) fw_env="leonardo" ;;
*) fw_env="UNKNOWN" ;;
esac
if [[ $(wc -l <<< "$mcu") -gt 1 ]]; then
warn "several candidate MCU ports found; pick one by hand:"
warn "$mcu"
mcu="CHOOSE_ONE"
fi
cat <<EOF
; hardware.ini - what this machine IS. Generated by hardware/$board/install.sh
; on $(date -Is) from what the machine reports, plus the assembly
; facts given on the command line. See config/hardware.example.ini for the full
; reference, and docs/hardware-config-plan.md for why this file exists.
[Board]
model = $board
firmware_env = $fw_env
[Hardware]
; DECLARED, not probed: these are what the tower was built with, so that a part
; that stops answering is a fault and not a silent reconfiguration.
axes = $AXES
imu = $IMU
env = $ENV_SENSOR
cameras = $CAMERAS
[Serial]
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
; enumerates first.
device = ${mcu:-NOT_FOUND_SET_THIS_BY_HAND}
baud = 115200
EOF
if [[ $IMU != none ]]; then
cat <<'EOF'
[IMU]
; On the IOTA the only PCH serial on the header is COM1. Confirm it enumerates
; and at what levels before trusting this path.
device = /dev/ttyS0
EOF
fi
if [[ $ENV_SENSOR != none ]]; then
cat <<'EOF'
[Env]
; MUST be a real I2C adapter, not SMBus-only: check with `i2cdetect -F <n>`.
; The IOTA exposes no PCH-side I2C on the header, so this is normally a USB-I2C
; bridge (MCP2221A), whose bus number depends on enumeration order.
i2c_device = SET_THIS_BY_HAND
i2c_addr = 0x44
EOF
fi
# One [Motor.<axis>] block per axis. The firmware holds none of these itself
# any more - it asks, and refuses to move until answered - so an axis without
# a block here is an axis that will not turn.
#
# A value we were not given is written COMMENTED OUT, with what to measure.
# Not as a placeholder string: these keys are numeric, and a non-numeric value
# makes the program refuse to start, which is a worse failure than the axis
# reporting itself undeclared.
local ax first=1
for ax in ${AXES//,/ }; do
ax="$(echo "$ax" | tr -d '[:space:]')"
[[ -z $ax ]] && continue
echo
echo "[Motor.$ax]"
if [[ $first -eq 1 ]]; then
kv_or_todo "r_sense_ohm" "$R_SENSE" \
"the resistor fitted to THIS driver board - read it off the board, do not assume"
kv_or_todo "run_current_ma" "$RUN_CURRENT" \
"coil current for normal motion, mA RMS, from the motor's nameplate"
kv_or_todo "homing_current_ma" "$HOMING_CURRENT" \
"current while hunting an endstop. LOWER than run_current wherever the stop can be out-pushed: on this rig a screw-head stop was sheared straight past its mate at full torque"
kv_or_todo "travel_deg" "$TRAVEL_DEG" \
"mechanical angle between the hard stops. Rough is fine to start; every bearing inherits its error proportionally until 'gimbal heading setup' supersedes it"
kv_or_todo "steps_per_rev" "$STEPS_PER_REV" \
"measured full travel IN COUNTS - read it off a homing run. The only guard against a PHANTOM endstop"
center_note "$STEPS_PER_REV" "$ax"
kv_or_todo "sw_mode" "$SW_MODE" \
"TMC SW_MODE. 0 = no physical switches (StallGuard homing); set the bits if this axis has REFL/REFR wired"
else
echo "; Nothing here can be probed, and this is not the first axis, so the"
echo "; values below are a template. Measure them on THIS axis."
kv_or_todo "r_sense_ohm" "" "sense resistor on this axis's driver board"
kv_or_todo "run_current_ma" "" "coil current for normal motion, mA RMS"
kv_or_todo "homing_current_ma" "" "current while hunting an endstop"
kv_or_todo "travel_deg" "" "mechanical angle between the hard stops"
kv_or_todo "steps_per_rev" "" "measured full travel, in counts"
kv_or_todo "sw_mode" "" "TMC SW_MODE (0 = no physical switches)"
fi
cat <<'EOF'
; Speeds are DEGREES PER SECOND, not VMAX: a register value means different
; things on different gear ratios, so it is not portable between towers. The
; host converts through counts_per_deg and sends SPEED.
scan_speed_deg_s = 8.0
homing_speed_deg_s = 4.0
center_speed_deg_s = 6.0
; |XACTUAL - XENC| that means "position untrustworthy". 1000 on this mechanism:
; the free-running gap measured 199-229 counts on tower 121, so this is ~4x
; headroom. Re-measure whenever the homing CURRENT changes - the gap is coupled
; to it, and notably NOT to speed (229 counts at VMAX 6000, 199 at 30000).
; Lowering is the safe direction; raising it blunts the only stall signal there is.
enc_deviation = 1000
; Is this axis's encoder aligned to XACTUAL and usable for hold and DIAG?
has_encoder = true
EOF
first=0
done
local c
for c in ${CAMERAS//,/ }; do
c="$(echo "$c" | tr -d '[:space:]')"
cat <<EOF
[Camera.$c]
; The Vimba DEV_ id of the physical camera fitted to this tower. Not the USB
; serial: read it with VimbaX's ListCameras, which is what this probe does.
id = ${cam:-NOT_FOUND_SET_THIS_BY_HAND}
EOF
done
}
do_hardware_ini() {
if [[ $TO_STDOUT -eq 1 ]]; then emit_hardware_ini; return; fi
need_root "--hardware-ini"
mkdir -p "$ETC_DIR"
if [[ -f $HW_FILE && $FORCE -ne 1 ]]; then
warn "$HW_FILE exists - leaving it alone (--force to replace)"
warn "the file it would write instead:"
emit_hardware_ini | sed 's/^/ /' >&2
return
fi
if [[ -f $HW_FILE ]]; then
cp -p "$HW_FILE" "$HW_FILE.bak-$(date +%Y%m%d-%H%M%S)"
say "backed the previous hardware.ini up"
fi
emit_hardware_ini > "$HW_FILE"
chmod 0644 "$HW_FILE"
say "wrote $HW_FILE"
grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" >/dev/null 2>&1 && {
warn "it still has placeholders - edit them before starting the app:"
grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" | sed 's/^/ /' >&2
}
return 0
}
# ------------------------------------------------------------------- main ---
board="$(probe_board)"
if [[ $board != lattepanda-iota ]]; then
warn "this kit is for the LattePanda IOTA; this machine reports '$board'"
# --probe and --stdout change nothing on this machine: one reports what it
# found, the other prints the file it WOULD write. Refusing them on a
# non-IOTA only means the generator can never be exercised off a tower,
# which is how a setup kit ends up untested.
[[ $PROBE_ONLY -eq 1 || $TO_STDOUT -eq 1 ]] ||
die "refusing to apply IOTA preconditions to a $board"
fi
if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi
# --stdout has to emit the file and nothing else: its whole purpose is being
# redirected into one. The banner belongs on stderr in that case, or it lands in
# the middle of the hardware.ini somebody just generated.
if [[ $TO_STDOUT -eq 1 ]]; then
do_hardware_ini
exit 0
fi
say "LattePanda IOTA setup kit"
probe_report | sed 's/^/ /'
# if/fi rather than '[[ ]] && cmd': under `set -e` a false test as the last
# command of an AND list exits the script.
if [[ $DO_DEPS -eq 1 ]]; then do_deps; fi
if [[ $DO_VIMBA -eq 1 ]]; then do_vimba; fi
if [[ $DO_PRE -eq 1 ]]; then do_preconditions; fi
if [[ $DO_HW -eq 1 ]]; then do_hardware_ini; fi
say "done"

View File

@ -0,0 +1,30 @@
# apt packages for a LattePanda IOTA tower host. One per line, '#' comments.
# Consumed by install.sh --deps.
# --- build toolchain -----------------------------------------------------
build-essential
cmake
git
pkg-config
# --- fire_gimbal_control build deps --------------------------------------
# Paho MQTT is fetched by cmake; OpenCV, Boost and libjxl are not.
libopencv-dev
libboost-program-options-dev
libjxl-dev
libssl-dev
# --- runtime -------------------------------------------------------------
# tmux: scripts/fgc runs the app detached inside a session.
tmux
rsync
# --- firmware build (PlatformIO runs ON the tower, see NOTES.md) ---------
python3-venv
python3-pip
# --- diagnostics ---------------------------------------------------------
# i2c-tools: `i2cdetect -F` proves a bus is real I2C and not SMBus-only.
# usbutils: lsusb, for camera and MCU enumeration.
i2c-tools
usbutils

View File

@ -0,0 +1,12 @@
# systemd-tmpfiles: raise the USB filesystem buffer for large camera frames.
#
# WHY: known-issues #19. usbcore.usbfs_memory_mb defaults to 16 MB, far below a
# single full-resolution frame, and reverts on every reboot. 1000 is the value
# the Allied Vision documentation asks for.
#
# 'w' writes the value if the file exists and never creates it, so this is inert
# on a machine without usbcore. Applied at boot by systemd-tmpfiles-setup.
# The kernel-cmdline alternative (usbcore.usbfs_memory_mb=1000 in GRUB) does the
# same job earlier; this one is chosen because it needs no bootloader edit and is
# undone by deleting one file.
w /sys/module/usbcore/parameters/usbfs_memory_mb - - - - 1000

View File

@ -0,0 +1,16 @@
# Alvium USB3 camera preconditions - fire-watch tower hosts.
#
# WHY: known-issues #19. On the camera's USB3 link the host re-enables hardware
# LPM (U1/U2) on every enumeration; the camera then logs link errors and frame
# delivery stalls. The setting lives on the PORT, not on the device, which is
# why this writes through the device's 'port' symlink rather than using ATTR{}.
#
# Verified on the IOTA 2026-09-15: /sys/bus/usb/devices/2-1/port ->
# ../2-0:1.0/usb2-port1, usb3_lpm_permit was 'u1_u2' out of the box.
#
# The MODE line duplicates what the Vimba X transport-layer installer writes to
# 99-AVTUSBTL.rules. Kept here so a host that has not run that installer yet
# still gets a camera it can open, and harmless where it has.
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", MODE="0666"
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", RUN+="/bin/sh -c 'echo 0 > /sys%p/port/usb3_lpm_permit 2>/dev/null || true'"

View File

@ -0,0 +1,18 @@
# RP2040 motor-controller MCU - fire-watch tower hosts (LattePanda IOTA).
#
# WHY: flashing the firmware needs write access to the board's USB device, and
# the default mode is root-only. Without this, picotool finds the board and then
# refuses it:
#
# RP2040 device at bus 1, address 4 appears to be in BOOTSEL mode, but
# picotool was unable to connect. Maybe try 'sudo' or check your permissions.
#
# Matching on the vendor alone covers every mode the board appears in, which is
# the point - it changes identity as it is flashed:
# 2e8a:0003 RP2 Boot BOOTSEL / mass storage, what picotool writes to
# 2e8a:0005 MicroPython the image these boards ship with
# 2e8a:000a Pico SDK CDC the gimbal firmware, once flashed
#
# uaccess covers a logged-in user; the 0666 mode covers a plain ssh session,
# which is how a tower is actually reached.
SUBSYSTEM=="usb", ATTRS{idVendor}=="2e8a", MODE="0666", TAG+="uaccess"

View File

@ -13,6 +13,10 @@ struct RuntimeOptions {
bool init = false; // run the endstop-finding init sequence
bool start = false; // begin capture automatically
bool demo = false; // demo mode (copy placeholder image instead of encoding)
// Start anyway when hardware.ini declares something this rig does not have.
// Field triage only: it overrides [Features] strict_presence, and the log
// says so in those words so it cannot become the normal way to run a tower.
bool degraded = false;
// Tri-state feature overrides (unset => use the config value).
std::optional<bool> use_mqtt;

72
include/fgc/AxisSpeed.h Normal file
View File

@ -0,0 +1,72 @@
#pragma once
#include <string>
namespace fgc {
// Axis speed as a physical quantity, and its conversion to the TMC5160's VMAX.
//
// VMAX is a register value in internal units, and what it MEANS depends on the
// drivetrain: 30000 is 21.8 deg/s on the Delta fleet and 6.0 deg/s on the IOTA
// tower, because their gear ratios differ by 3.6x. Copying the number between
// towers therefore produces a silently different machine, which is exactly what
// happened - both carry the same literal.
//
// Declaring degrees per second instead makes the intent portable and checkable:
// it is the quantity that determines image smear and panorama period, and it can
// be reasoned about without knowing a gear ratio.
//
// The conversion needs only counts_per_deg, which the host already derives per
// tower from the homed travel. That works because the firmware keeps XACTUAL
// (step space) and XENC (encoder space) on the SAME scale - see
// firmware/docs/hardware-notes.md, "Encoders track 1:1 but must be aligned to
// XACTUAL". If that ever stops being true, this conversion breaks and the step
// scale becomes a separate calibration.
// The TMC5160 ramp generator: v[counts/s] = VMAX * f_CLK / 2^24.
//
// 12 MHz is the internal oscillator's nominal frequency. Confirmed on the rig
// rather than taken on faith: the predicted 0.7153 counts/s per VMAX unit is
// within 2% of the 0.73 measured across three homing runs on tower 121.
constexpr double kTmcClockHz = 12'000'000.0;
constexpr double kTmcVmaxDiv = 16'777'216.0; // 2^24
// VMAX is a 23-bit field.
constexpr long kTmcVmaxMax = 0x7FFFFF;
// The fleet default, in the unit that means the same thing on every tower.
constexpr double kDefaultScanSpeedDegPerSec = 8.0;
// Below this, counts_per_deg is not a drivetrain - it is an uncalibrated axis
// still carrying AxisMap's default of 1.0.
//
// The floor is physical, not arbitrary: the AS5047P in ABI mode emits 4000 steps
// per revolution (COMPONENTS.md section 7), so even an axis driven with NO gear
// reduction reads 11.1 counts/deg. Anything under that cannot have come from a
// real encoder on this rig. Converting a speed through such a scale yields a
// VMAX near zero - an axis that looks configured and never finishes a sweep -
// so it is refused rather than commanded.
constexpr double kMinPlausibleCountsPerDeg = 10.0;
struct AxisSpeedPlan {
bool ok = false;
long vmax = 0;
double requested_deg_s = 0.0;
// What `vmax` actually delivers. VMAX is an integer, so this differs from the
// request by the quantisation - negligible at production speeds, but it is
// what the startup log should report, because it is what the axis does.
double actual_deg_s = 0.0;
bool clamped = false;
std::string error; // why, when !ok
};
// `counts_per_deg` may be negative to flip the direction sense (see Geometry.h);
// only its magnitude matters to a speed, so the sign is ignored rather than
// producing a negative VMAX.
AxisSpeedPlan planAxisSpeed(double deg_per_s, double counts_per_deg,
double clock_hz = kTmcClockHz);
// The inverse, for reporting what a firmware default or an existing literal
// actually amounts to on this drivetrain.
double vmaxToDegPerSec(long vmax, double counts_per_deg, double clock_hz = kTmcClockHz);
} // namespace fgc

View File

@ -19,6 +19,111 @@ struct GeneralConfig {
bool debug = false; // print motor telemetry each loop tick
};
// [Board]: which computer this is. Assembly data - it changes only when the
// machine is physically replaced, so it belongs in hardware.ini, not config.ini.
struct BoardConfig {
std::string model; // e.g. "lattepanda-3-delta", "lattepanda-iota"
std::string firmware_env; // PlatformIO env for the motor MCU ("leonardo", ...)
};
// [Hardware]: what is actually fitted to THIS rig.
//
// The point of declaring it is that the rest of the program degrades gracefully
// when something is missing - TestRunner skips the pitch and IMU leaves, CamEvent
// falls back to encoder pitch, CaptureScheduler sends the single-axis MOVE form -
// which is right for a rig that genuinely lacks them, and indistinguishable from a
// FAILURE on a rig that does not. Declaring what was assembled is what lets
// startup tell those two apart.
struct HardwareConfig {
// Empty means "not declared": nothing was stated, so nothing is reconciled.
// This is the state every existing tower is in until it gets a hardware.ini.
std::vector<std::string> axes; // e.g. {"yaw", "pitch"}
std::string imu; // sensor model, or "none"
std::string env; // sensor model, or "none"
std::vector<std::string> cameras; // the [Camera.<name>] blocks expected to exist
// Was a hardware.ini actually read? False on every tower that has not been
// migrated yet, and the switch that keeps this change invisible on rollout.
bool declared = false;
// Where it came from, for logging and for `test hardware` to cite.
std::string source_path;
// True when `name` is listed in axes. An UNDECLARED rig answers true for
// everything: with nothing stated there is nothing to contradict, and callers
// must keep behaving exactly as they did before.
bool hasAxis(const std::string& name) const;
// "yaw, pitch" - for log lines and test output.
std::string axesLabel() const;
};
// [Motor.<axis>] - what this tower's drivetrain IS, one block per axis. These
// change only when the motor, gearbox or driver board changes, so they belong in
// hardware.ini beside the rest of the assembly description.
//
// Currents are milliamps RMS per phase, NOT register values: the conversion needs
// the sense resistor fitted to this particular driver board, so the host owns it
// and sends the firmware the registers it computed (see TmcCurrent.h).
struct AxisDrivetrainConfig {
bool declared = false; // any [Motor.<axis>] key present at all
double r_sense_ohm = 0.0; // sense resistor on this axis's TMC board
double run_current_ma = 0.0; // 0 = undeclared -> nothing is sent
double homing_current_ma = 0.0; // defaults to run_current_ma
// Mechanical angle between the hard endstops. A nameplate fact, not a
// calibration: homing measures the travel in counts, and dividing by this
// yields [Motor] <axis>_counts_per_deg, which lives in config.ini and is
// saved with history. Keeping them in different files AND different keys is
// deliberate - hardware.ini is merged over config.ini and wins, so a shared
// key would silently discard every saved calibration.
double travel_deg = 0.0; // 0 = undeclared -> nothing is derived
// Production move speed, as a PHYSICAL quantity. VMAX is a register value
// whose meaning depends on the gear ratio - 30000 is 21.8 deg/s on the Delta
// fleet and 6.0 deg/s on the IOTA - so declaring it in register units makes
// the number unportable and, in practice, wrong when copied between towers.
// The host converts through counts_per_deg and sends SPEED; see AxisSpeed.h.
double scan_speed_deg_s = 8.0; // kDefaultScanSpeedDegPerSec
// Speeds for the two motions that are not production moves. Both are also
// deg/s, and both are needed BEFORE the first home - so they cannot use the
// counts_per_deg that homing derives. preHomeCountsPerDeg() below is the
// estimate they convert through.
double homing_speed_deg_s = 0.0; // 0 = undeclared -> firmware default stands
double center_speed_deg_s = 0.0;
// The mechanism's own facts, for the config handshake (FirmwareConfig.h).
// The firmware holds none of these once it asks for them.
long steps_per_rev = 0; // expected full mechanical travel, in counts
long enc_deviation = 0; // |XACTUAL-XENC| that means "position untrustworthy"
// Wiring: TMC SW_MODE (physical endstop switches on REFL/REFR), and whether
// this axis's encoder is aligned to XACTUAL and usable for hold and DIAG.
bool wiring_declared = false; // any of the two keys below present
unsigned sw_mode = 0;
bool has_encoder = true;
// counts_per_deg as the DECLARATION implies it, for use before the axis has
// been homed. Homing supersedes it by measuring the travel for real; on the
// IOTA the two agree to 0.25% (1210778/340 = 3561 against a measured
// 3552.87). Returns 0 when it cannot be formed.
double preHomeCountsPerDeg() const {
if (steps_per_rev <= 0 || travel_deg <= 0.0) return 0.0;
return static_cast<double>(steps_per_rev) / travel_deg;
}
// True when the homing current is genuinely lower than the run current, i.e.
// when the reduction is load-bearing and homing without it risks the rig.
bool reducesForHoming() const {
return run_current_ma > 0.0 && homing_current_ma > 0.0 &&
homing_current_ma < run_current_ma;
}
};
struct DrivetrainConfig {
AxisDrivetrainConfig yaw;
AxisDrivetrainConfig pitch;
};
struct NetworkConfig {
std::string broker_ip = "127.0.0.1"; // MQTT broker host/IP, or a full tcp://... URI
int mqtt_port = 1883; // ignored when broker_ip carries its own port
@ -103,8 +208,46 @@ struct FeaturesConfig {
bool mock_imu = false; // use a simulated IMU instead of the MTi
bool enable_env = false; // ambient temp/humidity sensor (off by default)
bool mock_env = false; // use a simulated env sensor instead of the SHT41
// Make the simulated motor controller run the config handshake: emit NEED
// lines at start and reject motion until they are answered. This is how the
// host half is exercised on a dev box with no firmware that asks yet.
bool mock_needcfg = false;
// Refuse to start when hardware.ini declares a subsystem this rig does not
// have (HardwarePresence.h). Off by default so that gaining a hardware.ini
// cannot change how a tower behaves: it is turned on per tower once that
// file is known good, and the fleet-wide flip is a config change rather than
// a release. --degraded overrides it for field triage.
bool strict_presence = false;
};
// Where a sensor's on/off state came from. [Hardware] imu / env DECLARE what is
// fitted; [Features] enable_imu / enable_env are the older flags that said the
// same thing a second time, and could disagree with it.
enum class SensorEnableSource {
Declaration, // [Hardware] imu / env said so - the new, single source
Flag, // no declaration, but the legacy flag was written explicitly
Default, // neither: the built-in default
};
struct SensorEnable {
bool enabled = false;
SensorEnableSource source = SensorEnableSource::Default;
// The legacy flag was present AND contradicted the declaration. The
// declaration wins; this exists so the stale key can be named and deleted
// rather than silently ignored.
bool conflict = false;
};
// Resolve one sensor's enablement.
//
// The declaration wins wherever it exists, which is what retires the flag: a rig
// that says `imu = mti-630` needs no second key agreeing with it, and a rig that
// says `imu = none` cannot be contradicted into enabling one. With nothing
// declared - every tower until it is migrated - the flag governs exactly as
// before.
SensorEnable resolveSensorEnable(const std::string& declared, bool flag_value, bool flag_present);
// [IMU]: Xsens MTi connected over the LattePanda's RS-232 UART (a hardware
// /dev/ttyS* node, stable across reboots — not a USB device).
struct ImuConfig {
@ -168,6 +311,13 @@ struct TestConfig {
std::string baseline_file; // empty => <logdir>/test_baseline.txt
std::map<std::string, TestProfile> profiles;
// `test hardware camera` USB preconditions. Both are machine facts rather
// than tuning: the threshold is what frame delivery needs (known-issues #19),
// and the LPM node's path differs per board, so it cannot be guessed. Left
// empty, the LPM check reports "not checked" rather than passing.
int usbfs_min_mb = 1000;
std::string usb3_lpm_path;
const TestProfile* find(const std::string& name) const; // nullptr if absent
};
@ -177,10 +327,17 @@ struct AppConfig {
SerialConfig serial;
CameraConfig camera;
PathsConfig paths;
BoardConfig board; // [Board] from hardware.ini
HardwareConfig hardware; // [Hardware] from hardware.ini
FeaturesConfig features;
LoggingConfig logging;
UiConfig ui; // [UI] terminal dashboard toggle
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
DrivetrainConfig drivetrain; // [Motor.<axis>] from hardware.ini
// [Motor] how long a full two-end homing may take before the host calls it
// failed. A property of the drivetrain, not of this program: a high gear
// ratio searching at a gentle speed takes minutes.
int homing_timeout_s = 65;
// [Motor] clearance kept from each hard endstop, in counts. Sent to the
// firmware (MARGIN), which enforces it; kept in counts rather than degrees
// because it must apply at homing time, before any degree map is trusted.
@ -205,7 +362,14 @@ public:
// Reads the INI file at `path` (via the bundled inih parser), maps it into
// a typed AppConfig, applies environment overrides, and validates it.
// Throws std::runtime_error with a clear message on failure.
static AppConfig loadFromFile(const std::string& path);
//
// `hardware_path`, when given, is read AFTER the config and its keys win. It
// carries the assembly-time description of the machine ([Board], [Hardware],
// and the device bindings), which outlives any amount of operational tuning.
// Passing "" keeps the historical behaviour exactly: everything comes from
// config.ini and `hardware.declared` stays false.
static AppConfig loadFromFile(const std::string& path,
const std::string& hardware_path = "");
// Same mapping/validation logic, but from an already-parsed key->value map
// ("Section.name" => value). Exposed for unit testing without file IO.
@ -216,14 +380,29 @@ public:
// existing keys in that section are replaced in place (comments/order preserved),
// and any missing ones are appended in a `[section]` block at the end. Pure (no
// I/O) so it is unit-testable.
//
// With a non-empty `provenance`, a key whose value actually CHANGES also gets its
// previous value kept above it as a comment, so a calibration can be reverted by
// hand:
//
// ;prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal heading setup
// yaw_heading_at_center = 127.4
//
// Exactly one generation is kept - an existing `;prev` for a changing key is
// replaced, not appended to - and unchanged keys are left completely alone, so
// the file grows once and then holds steady.
std::string updateIniSectionKeys(const std::string& contents, const std::string& section,
const std::vector<std::pair<std::string, std::string>>& kv);
const std::vector<std::pair<std::string, std::string>>& kv,
const std::string& provenance = "");
// Persist the live `[Motor]` geometry (counts_per_deg / center_count /
// deg_at_center for both axes, plus the endstop margins) back into the INI file
// at `path`. Written via a temp file and rename, so an interrupted save cannot
// leave a truncated config behind. Returns true on success.
//
// `provenance` names what produced these values (e.g. "2026-09-11T14:32Z, gimbal
// heading setup"); when given, superseded values are kept as `;prev` comments.
bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin,
long pitch_margin);
long pitch_margin, const std::string& provenance = "");
} // namespace fgc

View File

@ -21,6 +21,27 @@ struct DumpAxis {
long center_count = 0; // count the firmware anchored the homed midpoint to
long hold_target = 0;
long speed = 0;
// Motor current, as the firmware believes it applied it. GLOBALSCALER and
// IHOLD_IRUN are write-only on the TMC5160, so this echo is the only way the
// host can confirm a CURRENT command landed.
//
// -1 means the field was absent, i.e. the firmware predates the command -
// which is a different thing from "applied a current of zero" and has to stay
// distinguishable, because one is a version skew and the other is a fault.
long global_scaler = -1;
long run_irun = -1;
long homing_irun = -1;
// The rest of what the host declares and the firmware is told. Same -1
// convention and the same reason: absent means a firmware that predates the
// config handshake, which is a version skew, not a value of zero.
long steps_per_rev = -1;
long enc_deviation = -1;
long homing_speed = -1;
long center_speed = -1;
long sw_mode = -1;
// Bitmask over the firmware's CfgKey: which required keys have arrived.
// -1 when absent.
long cfg_received = -1;
// Encoder-tracking health accumulated by the firmware hold corrector.
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
long hold_peak_dev = 0; // largest deviation seen while holding (counts)
@ -45,7 +66,19 @@ struct DumpData {
bool valid = false;
std::string build;
long uptime_ms = 0;
// Why the MCU last restarted, as the firmware reported it.
//
// `mcusr` is the RAW value and means DIFFERENT THINGS per board: on AVR it is
// the MCUSR bitmask (bit 0 power-on, bit 1 external, ...); on RP2040 it is
// rp2040.getResetReason(), a plain enum where 3 is one value, not two bits.
// Decoding one as the other is silently wrong - it reported an RP2040 soft
// reset as "PORF (power-on), EXTRF (external)" on tower 121.
//
// So the firmware now NAMES it in the dump (`reset=<text>`), and that text is
// authoritative because only the board knows its own encoding. reset_flags is
// the legacy AVR bit decode, kept for firmware that predates the field.
unsigned mcusr = 0;
std::string reset_text; // empty => firmware did not name it
long free_ram = 0;
std::vector<std::string> reset_flags;
std::vector<DumpAxis> axes;

View File

@ -0,0 +1,93 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
namespace fgc {
// The host half of the config handshake (hardware-config-plan.md section 6).
//
// Today the firmware carries a full parameter table and GUESSES when the host
// says nothing, so the same drivetrain value exists in two places and they can
// disagree. Instead the firmware declares what it needs and refuses to move
// until it has it:
//
// firmware -> BOOT build=abc123
// firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation ...
// host -> SET Y steps_per_rev 1210778
// host -> SET Y global_scaler 80
// ...
//
// This module is the pure part: read a NEED line, and work out which SET lines
// answer it. Sending them, and deciding what to do when some cannot be answered,
// belongs to the caller.
// A NEED line: the firmware naming the parameters it will not run without.
struct NeedRequest {
char axis = '?'; // 'Y' or 'P'
std::vector<std::string> keys;
};
// Returns nullopt for anything that is not a well-formed NEED line. A NEED with
// no keys is well-formed and means "this axis needs nothing" - which is how a
// firmware built for fewer axes than the rig has answers for the axis it lacks.
std::optional<NeedRequest> parseNeedLine(const std::string& line);
// Everything the host can answer for one axis. Each group carries its own
// "declared" flag because a value the host does not have must be reported as
// unanswered rather than sent as a zero - a zero here is a real setting, and a
// silently zeroed current or margin is a damaged rig.
struct AxisConfigSource {
// From [Motor.<axis>] milliamps, via TmcCurrent.h.
bool have_current = false;
int global_scaler = 0;
int run_irun = 0;
int homing_irun = 0;
// From [Motor] calibration and margins. Always known - they have defaults
// that predate hardware.ini and are already sent as CENTER / MARGIN.
long center_count = 0;
long soft_margin = 0;
// From [Motor.<axis>], the mechanism's own facts.
bool have_travel = false;
long steps_per_rev = 0; // expected full mechanical travel, in counts
bool have_deviation = false;
long enc_deviation = 0;
// Speeds, already converted from deg/s to VMAX by the caller (AxisSpeed.h).
// 0 means the conversion was refused - an uncalibrated scale - and the key
// goes unanswered rather than stopping the axis with a VMAX of zero.
long normal_vmax = 0;
long homing_vmax = 0;
long center_vmax = 0;
// Wiring facts: whether this axis has physical endstop switches on the TMC's
// REFL/REFR, and whether its encoder is aligned and usable.
bool have_wiring = false;
unsigned sw_mode = 0;
bool has_encoder = true;
};
struct FirmwareConfigPlan {
// Ready-to-send command lines, in the order the firmware asked for them.
std::vector<std::string> sets;
// Keys the host was asked for and has no value for. The firmware stays in
// NEEDCFG until they are answered, so this is a startup failure, not a
// warning to scroll past.
std::vector<std::string> unanswered;
// Keys that are not part of the vocabulary at all: a firmware newer than
// this host. Kept separate from `unanswered` because the fix is different -
// upgrade the host, rather than fill in hardware.ini.
std::vector<std::string> unknown;
bool complete() const { return unanswered.empty() && unknown.empty(); }
};
// Map one NEED request onto SET lines. `center` / `margin` are accepted as
// aliases for `center_count` / `soft_margin`, because those are the names the
// existing CENTER and MARGIN commands use and hand-typed sessions rely on them.
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src);
} // namespace fgc

View File

@ -0,0 +1,107 @@
#pragma once
#include <string>
namespace fgc {
// The decisions behind `test hardware`, separated from the I/O that feeds them.
//
// Each function takes what was read - a sysfs string, an ioctl bitmask, a DUMP
// echo - and returns a verdict with the sentence an operator needs. Keeping them
// pure is what lets the thresholds be tested without a rig, which matters because
// several of them encode traps that cost real debugging time (known-issues #2
// and #19) and must not drift silently.
struct CheckResult {
bool ran = true; // false => nothing was declared, so nothing was checked
bool pass = true;
std::string detail; // one line; names the fix when it failed
};
// /sys/module/usbcore/parameters/usbfs_memory_mb.
//
// Reverts to 16 on every reboot unless persisted on the kernel cmdline, and
// frame delivery stalls below ~1000 regardless of application code
// (known-issues #19). A value that will not parse is a failure, not a pass:
// an unreadable node means the check did not happen.
CheckResult checkUsbfsMemory(const std::string& sysfs_value, int min_mb);
// A usb3_lpm_permit node. U1/U2 link power management must be OFF on the
// camera's port or the camera logs frame drops under load; the per-device
// usb3_hardware_lpm_u* files are read-only, so this port-level node is the one
// that can actually be set (known-issues #19).
CheckResult checkUsb3Lpm(const std::string& permit_value);
// The I2C_FUNCS ioctl bitmask for the bus the SHT41 is on.
//
// An SMBus-only adapter opens, binds its slave address, and then fails every
// transaction - which reads as a dead sensor rather than as the wrong bus
// (known-issues #2). `i2cdetect -F <n>` is the manual form of this check.
//
// The bit is spelled out rather than pulled from <linux/i2c.h> so this stays a
// pure module: I2C_FUNC_I2C is 0x00000001 and is ABI-stable.
constexpr unsigned long kI2cFuncI2c = 0x00000001UL;
CheckResult checkI2cFunctionality(unsigned long funcs);
// What the host computed from hardware.ini for one axis.
struct DrivetrainExpectation {
bool declared = false; // hardware.ini carries a current for this axis
int global_scaler = 0;
int run_irun = 0;
int homing_irun = 0;
};
// One value the host declared and the firmware should be holding. Used for the
// parameters the config handshake added, where "not declared" and "not echoed"
// both mean the check does not apply rather than that it failed.
struct DeclaredValue {
const char* name = "";
bool want = false; // hardware.ini declares it
long value = 0;
long got = -1; // as DUMP echoed it; -1 = absent
};
// Does the firmware hold what hardware.ini says? This is the generalisation of
// the old "did CENTER land?" check: with the drivetrain owned by the host, every
// declared value is one the firmware can be silently wrong about, and a tower
// running on stale or defaulted values looks identical to a correct one from the
// outside.
//
// `values` may be empty, which is how an axis with nothing declared reports
// "not checked" rather than passing.
CheckResult checkDeclaredValues(const std::string& axis, const DeclaredValue* values, int count);
// What DUMP echoed back. -1 means the field was absent, which is a firmware
// predating the CURRENT command - a version skew, not a current of zero, and
// the two must stay distinguishable.
struct DrivetrainEcho {
long global_scaler = -1;
long run_irun = -1;
long homing_irun = -1;
bool absent() const { return global_scaler < 0 && run_irun < 0 && homing_irun < 0; }
};
// Did the firmware adopt what the host sent? Both registers are write-only on
// the TMC5160, so this echo is the only confirmation available - and without it
// a tower can run its whole life on limp-home defaults while reporting healthy.
CheckResult checkDrivetrainAdopted(const std::string& axis, const DrivetrainExpectation& want,
const DrivetrainEcho& got);
// Does the homed travel fit entirely in positive counts?
//
// `center_count` defines where the middle of the found travel sits, so the range
// is center +/- half the travel. A center smaller than half the travel puts the
// negative end below zero. The 500000 default was sized for the Delta, whose
// half-travel is ~89k; a drivetrain geared up by 7x has ~605k and crosses zero.
//
// Whether the TMC5160 actually misbehaves there is NOT established - XACTUAL is
// a signed 32-bit register and the firmware round-trips the sign correctly - but
// homing is the only thing that has ever driven that far, so the region is
// untested in normal operation. The cheap fix is to not be there.
//
// `center` and `half_travel` are in counts. A non-positive half_travel means the
// axis has not homed, and nothing is checked.
CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel);
} // namespace fgc

View File

@ -0,0 +1,92 @@
#pragma once
#include "fgc/Config.h"
#include <string>
#include <vector>
namespace fgc {
// Reconciling what hardware.ini DECLARES against what the rig actually turned
// out to have.
//
// The program degrades gracefully everywhere - TestRunner skips the pitch and
// IMU leaves, CamEvent falls back to encoder pitch, CaptureScheduler sends the
// single-axis MOVE form. That is right for a rig that genuinely lacks them, and
// indistinguishable from a FAILURE on a rig that does not: a 2-axis tower whose
// pitch stops enumerating becomes a yaw-only tower, captures one elevation band,
// and reports itself healthy. Declaring the assembly is what lets startup tell
// those two apart.
// What this run actually found, from the sources that can answer: telemetry for
// the axes, the constructed sensor objects for the IMU and env sensor, camera
// enumeration for the cameras.
struct FoundHardware {
std::vector<std::string> axes; // yaw implied by any ST line, pitch by pitch_present
bool imu = false;
bool env = false;
std::vector<std::string> cameras; // by [Camera.<name>] block name, as configured
// How many of those the source actually opened. A camera can be configured
// and declared and still not be on the bus, which is the whole failure this
// section exists to catch - but the source reports a count, not which ones
// are missing, so the shortfall is named as a number. -1 = not asked.
int cameras_opened = -1;
// Telemetry never arrived, so the axis list is unknown rather than empty.
// Without this an unplugged MCU would report every declared axis as missing,
// which is true but useless - the one fault worth naming is the serial link.
bool axes_known = true;
};
enum class PresenceIssue {
MissingDeclared, // declared, not found - the failure this exists to catch
UndeclaredFound, // found, not declared - the file is stale
};
struct PresenceFinding {
PresenceIssue issue;
std::string subsystem; // "axis pitch", "imu", "env", "camera rgb"
std::string detail; // one operator-readable sentence, naming the fix
};
struct PresenceReport {
std::vector<PresenceFinding> findings;
// False when no hardware.ini was read, or it declared nothing comparable.
// Every tower is in this state until it is migrated, and it is what keeps
// this change invisible during the rollout.
bool reconciled = false;
bool anyMissing() const;
bool anyUndeclared() const;
std::vector<std::string> missing() const; // subsystem names, for the refusal line
};
// Pure comparison; says nothing about what to do about it.
PresenceReport reconcilePresence(const HardwareConfig& declared, const FoundHardware& found);
enum class PresenceAction {
Proceed, // nothing to reconcile, or everything matches
WarnOnly, // mismatches, but policy (or --degraded) says carry on
Refuse, // declared hardware is missing and this tower is strict
};
struct PresenceVerdict {
PresenceAction action = PresenceAction::Proceed;
std::string reason; // the line to log; empty when there is nothing to say
};
// `strict` is [Features] strict_presence, `degraded` is --degraded.
//
// Warn-only is the default deliberately: through the fleet rollout no tower may
// change behaviour on upgrade just by gaining a hardware.ini. A tower flips to
// strict once its file is known good, so the fleet-wide flip is a config change
// rather than a release.
PresenceVerdict presenceVerdict(const PresenceReport& report, bool strict, bool degraded);
// Every finding as one log-ready line, most serious first. Empty when there are
// none.
std::vector<std::string> formatPresence(const PresenceReport& report);
} // namespace fgc

View File

@ -1,11 +1,18 @@
#pragma once
#include <string>
#include <vector>
namespace fgc {
// Per-axis lifecycle state, from the firmware ST line's state char (B/R/H/A/E).
enum class AxisState { Boot, Reset, Homing, Ready, Error, Unknown };
// Per-axis lifecycle, from the firmware ST line's state char.
// B Boot R Reset H Homing A reAdy E Error C NeedConfig
//
// NeedConfig means the firmware has been told nothing about the drivetrain it is
// driving and is refusing to move until the host answers its NEED lines. It is
// NOT a fault and NOT "no telemetry": the link is fine and the axis is talking.
enum class AxisState { Boot, Reset, Homing, Ready, Error, NeedConfig, Unknown };
// One axis segment of a firmware `ST` status line:
// ST Y:<state>,<xactual>,<xenc>,<drv_hex>,<sg>,<cs>,<pwm>,<flags> [P:...]
@ -70,6 +77,14 @@ public:
virtual std::string lastDiag() = 0;
virtual unsigned diagSeq() const = 0;
// NEED lines the firmware has emitted since they were last taken, one per
// axis, in arrival order (hardware-config-plan.md section 6). Taking them
// clears the queue: they are a request to answer once, not a state to poll.
//
// Default: none. Only the real controller sees the wire; the mock has no
// firmware to ask, and a null implementation would have nothing to say.
virtual std::vector<std::string> takeNeedLines() { return {}; }
// Whether the underlying link is usable.
virtual bool connected() const = 0;
};

View File

@ -22,6 +22,28 @@ std::vector<std::string> configSearchPaths(const std::string& cliArg = "");
// readable file wins. Returns nullopt if none is found.
std::optional<std::string> resolveConfigPath(const std::string& cliArg = "");
// The ordered list of locations searched for hardware.ini - the assembly-time
// description of THIS machine (which board, which sensors are fitted, which
// device nodes they are on). Deliberately NOT derived from the config.ini path:
//
// --hardware <path> explicit, for dev and testing
// $FGC_HARDWARE the service environment
// /etc/fire_gimbal_control/hardware.ini the normal answer on a tower
// <dir of config.ini>/hardware.ini so a dev checkout just works
//
// /etc is where credentials.env already lives: root-owned, per-device, and
// OUTSIDE the deploy tree, so an rsync cannot overwrite or delete it. Keeping the
// assembly description there makes "a deploy never touches it" a property of
// where the file is, not of an exclude rule someone has to maintain.
std::vector<std::string> hardwareSearchPaths(const std::string& cliArg = "",
const std::string& configPath = "");
// Resolve hardware.ini using hardwareSearchPaths(); first existing file wins.
// Returns nullopt when there is none - which is not an error: the program then
// falls back to reading the assembly keys from config.ini as it always has.
std::optional<std::string> resolveHardwarePath(const std::string& cliArg = "",
const std::string& configPath = "");
// Default image output directory when none is configured
// ($XDG_DATA_HOME/fire_gimbal_control/images, else ~/.local/share/...).
std::string defaultOutputDir();

View File

@ -4,6 +4,7 @@
#include <memory>
#include <string>
#include <vector>
namespace fgc {
@ -23,6 +24,7 @@ public:
std::string lastDump() override;
std::string lastDiag() override;
unsigned diagSeq() const override;
std::vector<std::string> takeNeedLines() override;
bool connected() const override;
private:

View File

@ -15,6 +15,10 @@ namespace fgc {
class IMotorController;
class IImuSource;
class IEnvSensor;
class ICameraSource;
struct HardwareConfig;
struct DrivetrainConfig;
// Leaf-test selection bitmask. Subsystem masks are unions of their leaves.
enum TestLeaf : uint32_t {
@ -30,14 +34,27 @@ enum TestLeaf : uint32_t {
T_HOST_DISK = 1u << 9,
T_HOST_MEMORY = 1u << 10,
T_HOST_LOAD = 1u << 11,
// `test hardware`: is this rig the one hardware.ini says it is? These are
// preconditions rather than measurements - each either holds or does not, so
// they carry pass/fail metrics rather than values to trend.
T_HW_AXES = 1u << 12,
T_HW_MOTOR = 1u << 13,
T_HW_IMU = 1u << 14,
T_HW_ENV = 1u << 15,
T_HW_CAMERA = 1u << 16,
};
constexpr uint32_t T_GIMBAL_ALL = T_GIMBAL_HOMING | T_GIMBAL_ENCODER | T_GIMBAL_FRICTION |
T_GIMBAL_BACKLASH | T_GIMBAL_BALANCE;
constexpr uint32_t T_IMU_ALL = T_IMU_CONFIG | T_IMU_HEALTH | T_IMU_DRIFT;
constexpr uint32_t T_HOST_ALL = T_HOST_THERMAL | T_HOST_DISK | T_HOST_MEMORY | T_HOST_LOAD;
constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL;
constexpr uint32_t T_HW_ALL = T_HW_AXES | T_HW_MOTOR | T_HW_IMU | T_HW_ENV | T_HW_CAMERA;
constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL | T_HW_ALL;
// Which leaf masks need real motor / IMU hardware (used by the caller to guard).
// Needs a HOMED gimbal, not merely a motor link: the caller refuses the whole
// run when it is not ready. The `test hardware` leaves are deliberately NOT
// here - they only read telemetry and DUMP, and they are the checks you want
// on a tower that has not been homed yet.
constexpr uint32_t T_NEEDS_MOTOR = T_GIMBAL_ALL;
constexpr uint32_t T_NEEDS_IMU = T_IMU_ALL;
@ -47,6 +64,30 @@ constexpr uint32_t T_NEEDS_IMU = T_IMU_ALL;
bool resolveTestSelection(const std::string& subsystem, const std::string& test,
uint32_t& mask, std::string& err);
// Everything the `test hardware` leaves need beyond the motor and IMU the runner
// already holds. Passed as one struct because the alternative is five more
// constructor parameters, and because every field is optional: a leaf whose
// input is absent reports "not run" rather than failing.
struct HardwareTestContext {
const HardwareConfig* declared = nullptr; // [Hardware], or null when undeclared
const DrivetrainConfig* drive = nullptr; // [Motor.<axis>], for the current echo
IEnvSensor* env = nullptr;
ICameraSource* camera = nullptr;
std::vector<std::string> configured_cameras; // [Camera.<name>] blocks in open order
std::string i2c_device; // [Env] i2c_device, for the adapter-class check
// The degree maps, so a declared deg/s can be turned back into the VMAX the
// firmware should be holding.
AxisMap geo_yaw;
AxisMap geo_pitch;
double r_sense_ohm = 0.0; // to recompute what CURRENT should have sent
// sysfs nodes, overridable so the checks are exercisable off a tower.
std::string usbfs_path = "/sys/module/usbcore/parameters/usbfs_memory_mb";
std::string lpm_path; // empty => discovered from the camera's USB port, or skipped
int usbfs_min_mb = 1000;
};
// Live progress for the activity strip (mirrors CalibProgress).
struct TestProgress {
bool running = false;
@ -64,7 +105,8 @@ struct TestProgress {
class TestRunner {
public:
TestRunner(IMotorController& motor, IImuSource* imu, Geometry geo,
uint32_t selection, TestProfile profile, std::string disk_path);
uint32_t selection, TestProfile profile, std::string disk_path,
HardwareTestContext hw = {});
~TestRunner();
bool start();
@ -89,6 +131,11 @@ private:
void testImuHealth(TestReport& r);
void testImuDrift(TestReport& r);
void testHost(TestReport& r);
void testHwAxes(TestReport& r);
void testHwMotor(TestReport& r);
void testHwImu(TestReport& r);
void testHwEnv(TestReport& r);
void testHwCamera(TestReport& r);
IMotorController& motor_;
IImuSource* imu_;
@ -96,6 +143,7 @@ private:
uint32_t selection_;
TestProfile profile_;
std::string disk_path_;
HardwareTestContext hw_;
std::thread thread_;
std::atomic<bool> running_{false};

50
include/fgc/TmcCurrent.h Normal file
View File

@ -0,0 +1,50 @@
#pragma once
//
// TmcCurrent — milliamps to TMC5160 register fields.
//
// The driver expresses coil current as two dimensionless knobs: GLOBALSCALER
// (0x0B) scales the whole bridge, and IRUN (a field of IHOLD_IRUN, 0x10) scales
// within that. What a given pair actually delivers depends on the sense resistor
// soldered to the board, which is why this lives on the host: hardware.ini knows
// the resistor, the firmware does not.
//
// I_rms = (GLOBALSCALER/256) * ((IRUN+1)/32) * (V_FS / (R_SENSE + 0.02)) / sqrt(2)
//
// V_FS is 0.325 V for the DRVCONF the firmware writes, and the 0.02 ohm is the
// TMC5160's internal contribution in series with the external resistor.
//
#include <string>
#include <vector>
namespace fgc {
// RMS phase current in milliamps for a given register pair. Returns 0 for a
// non-positive sense resistor rather than dividing by something meaningless.
double tmcRmsCurrentMa(int global_scaler, int irun, double r_sense_ohm,
double v_fs = 0.325);
struct TmcCurrentPlan {
bool ok = false;
int global_scaler = 0; // 32..255. Never 0: on the TMC that means 256, i.e.
// FULL SCALE, so a defaulted or truncated zero would
// silently mean maximum current.
int run_irun = 0; // 0..31, normal motion
int homing_irun = 0; // 0..31, endstop search only
// What the chosen registers actually deliver — always recomputed, never the
// requested value echoed back, so the log cannot flatter the result.
double run_ma = 0.0;
double homing_ma = 0.0;
std::vector<std::string> warnings; // caller logs each at WARN
};
// One GLOBALSCALER serves both currents: it is chosen so the RUN current lands at
// IRUN = 31 (microstep resolution is best at the top of the IRUN range, and that
// is where the axis spends its working life), and the homing IRUN is then derived
// at that same scaler. The firmware therefore switches modes with a single
// register write, and never re-scales a moving motor.
TmcCurrentPlan planTmcCurrent(double r_sense_ohm, double run_ma, double homing_ma,
double v_fs = 0.325);
} // namespace fgc

View File

@ -3,9 +3,12 @@
#include "fgc/IMotorController.h"
#include "fgc/Logger.h"
#include <map>
#include <mutex>
#include <set>
#include <sstream>
#include <string>
#include <vector>
namespace fgc {
@ -16,7 +19,39 @@ namespace fgc {
// move -> settle -> trigger loop runs exactly as it would against real hardware.
class MockMotorController : public IMotorController {
public:
void start() override { LOG_INFO << "[mock] motor controller started"; }
// `needcfg`: emit NEED lines on start and refuse to move until they are
// answered, the way the real firmware will (hardware-config-plan.md
// section 6). Off by default so every existing test sees the old mock.
//
// This is what lets the HOST half of the handshake be built and verified
// before the firmware half exists - and it is not throwaway: it stays as the
// way the handshake is exercised on a dev box with no rig.
explicit MockMotorController(bool needcfg = false) : needcfg_(needcfg) {}
void start() override {
LOG_INFO << "[mock] motor controller started";
if (!needcfg_) return;
std::lock_guard<std::mutex> lock(mutex_);
static const char* kKeys[] = {"steps_per_rev", "global_scaler", "run_irun",
"homing_irun", "sw_mode", "enc_deviation",
"normal_speed", "homing_speed", "center_speed",
"center", "margin"};
need_lines_.clear();
unanswered_.clear();
for (const char* axis : {"Y", "P"}) {
std::string line = std::string("NEED ") + axis;
for (const char* k : kKeys) {
line += std::string(" ") + k;
// Track the same set the line asks for, so the axis unlocks only
// when EVERY key has been answered - a partial answer must leave
// it in NEEDCFG, which is the behaviour worth simulating.
unanswered_.insert(std::string(axis) + "." + k);
}
need_lines_.push_back(line);
}
configured_ = false;
LOG_INFO << "[mock] NEEDCFG: asking the host for config, refusing to move until answered";
}
void stop() override { LOG_INFO << "[mock] motor controller stopped"; }
void sendCommand(const std::string& cmd) override {
@ -27,6 +62,21 @@ public:
in >> verb;
for (auto& c : verb) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (verb == "SET") {
// Record what the host answered, so a test can assert the handshake
// actually put the right values on the wire.
std::string axis, key, value;
in >> axis >> key >> value;
settings_[axis + "." + key] = value;
unanswered_.erase(axis + "." + key);
if (unanswered_.empty()) configured_ = true;
return;
}
if (needcfg_ && !configured_ &&
(verb == "HOME" || verb == "MOVE" || verb == "ENABLE")) {
LOG_WARN << "[mock] ERR need config (" << verb << " rejected)";
return;
}
if (verb == "HOME") {
homed_ = true;
deviation_ = false; // homing clears the latch, as on the real driver
@ -37,6 +87,8 @@ public:
deviation_ = true;
} else if (verb == "RESET") {
deviation_ = false;
// A RESET is not a way out of NEEDCFG (firmware motor.cpp says why).
if (needcfg_ && !configured_) homed_ = false;
} else if (verb == "MOVE") {
std::string a;
in >> a;
@ -59,14 +111,40 @@ public:
// and main.cpp) so the host-side DumpParser is exercised without
// hardware. Register values are illustrative, not simulated; the
// encoder positions track the mock's live state.
const int st = homed_ ? 3 : 1; // READY : RESET
// 5 = NEEDCFG, appended to the firmware enum so the older codes keep
// their meanings. See motor.h.
const int st = (needcfg_ && !configured_) ? 5 : (homed_ ? 3 : 1);
std::ostringstream d;
d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096\n";
auto axis = [&](char L, const AxisTelemetry& a) {
// In NEEDCFG mode, echo back what the host actually SET, so the
// whole round trip - NEED, SET, DUMP, `test hardware motor` -
// can be exercised with no firmware. Outside it, stay on the old
// fixed line: that is a firmware predating the handshake, and
// the host must keep telling the two apart.
std::string owned = " speed=200000 center=500000 soft_margin=5000";
if (needcfg_) {
const std::string ax(1, L);
auto val = [&](const char* k, const char* dflt) {
auto it = settings_.find(ax + "." + k);
return it == settings_.end() ? std::string(dflt) : it->second;
};
owned = " speed=" + val("normal_speed", "200000") +
" center=" + val("center", "500000") +
" soft_margin=" + val("margin", "5000") +
" steps_per_rev=" + val("steps_per_rev", "0") +
" enc_dev=" + val("enc_deviation", "0") +
" homing_speed=" + val("homing_speed", "0") +
" center_speed=" + val("center_speed", "0") +
" sw_mode=" + val("sw_mode", "0") +
" gs=" + val("global_scaler", "0") +
" irun=" + val("run_irun", "0") +
" irun_home=" + val("homing_irun", "0");
}
d << "DUMP " << L << " state=" << st
<< " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< " speed=200000 center=500000 soft_margin=5000"
<< owned
<< " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
@ -131,9 +209,29 @@ public:
}
unsigned diagSeq() const override { return diag_seq_; }
std::vector<std::string> takeNeedLines() override {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> out;
out.swap(need_lines_);
return out;
}
// Test/inspection hooks: what the host SET, and whether the axis is unlocked.
std::map<std::string, std::string> settings() const {
std::lock_guard<std::mutex> lock(mutex_);
return settings_;
}
bool configured() const { return configured_; }
bool connected() const override { return true; }
private:
bool needcfg_ = false;
bool configured_ = false;
std::vector<std::string> need_lines_;
std::map<std::string, std::string> settings_;
std::set<std::string> unanswered_;
static constexpr long kStep = 80000; // counts advanced per telemetry read
void step(AxisTelemetry& a, long target) {
@ -146,7 +244,10 @@ private:
a.standstill = false;
}
a.xactual = a.xenc;
a.state = homed_ ? AxisState::Ready : AxisState::Reset;
// An axis waiting on its config says so, rather than looking like a
// healthy un-homed one - that distinction is the whole point of the state.
a.state = (needcfg_ && !configured_) ? AxisState::NeedConfig
: (homed_ ? AxisState::Ready : AxisState::Reset);
a.deviation = deviation_;
}
@ -156,7 +257,7 @@ private:
} catch (const std::exception&) {}
}
std::mutex mutex_;
mutable std::mutex mutex_;
AxisTelemetry yaw_;
AxisTelemetry pitch_;
long yaw_target_ = 0;

View File

@ -33,6 +33,12 @@ public:
const std::string& device() const { return device_; }
uint8_t address() const { return addr_; }
// The adapter's I2C_FUNCS bitmask, i.e. what class of transactions this bus
// can actually do. An SMBus-only adapter opens and binds exactly like a real
// one and then fails every transfer, so this is the difference between "the
// sensor is dead" and "this was never an I2C bus". Requires an open bus.
bool functionality(unsigned long& funcs) const;
private:
std::string device_;
uint8_t addr_;

View File

@ -3,6 +3,7 @@
#include "fgc/ui/IUserInterface.h"
#include <atomic>
#include <chrono>
#include <deque>
#include <mutex>
#include <string>
@ -46,7 +47,15 @@ private:
std::atomic<bool> running_{false};
// When set, the 100ms auto-redraw is suspended so the screen holds still and
// the operator can select/copy text (terminals drop a selection on repaint).
// Display freeze (the `p` key), for selecting text out of the pane. While
// set, refreshLoop() stops posting redraws, so the frame on screen ages.
// paused_at_ is when it was frozen, so the badge can say how stale it is.
std::atomic<bool> paused_{false};
// Wall-clock time of the freeze, "HH:MM:SS", shown in the badge. Captured
// once: the badge must stay truthful WITHOUT a repaint, because repainting
// would pull the live snapshot and unfreeze what the pause is holding still.
std::string paused_at_hms_;
static std::string nowHms();
// True while the gimbal overlay is open, so the refresh loop can periodically
// re-pull a firmware dump (keeps the tracking counters ticking without 'd').
std::atomic<bool> gimbal_overlay_open_{false};

View File

@ -289,11 +289,22 @@ struct CalibAxisView {
double r2 = 0;
int n = 0;
};
// The degrees<->counts map an axis is actually using right now: from config.ini
// at startup, replaced in memory by a fit or a homing derivation. Distinct from
// CalibAxisView, which describes the last FIT and only exists once one has run.
struct AxisMapView {
double counts_per_deg = 0;
double deg_at_center = 0;
};
struct CalibResultView {
bool has = false;
bool has = false; // has a fit run THIS SESSION?
long long ts_ms = 0;
bool pitch_present = false;
CalibAxisView yaw, pitch;
// Always populated. "No fit this session" is not the same as "uncalibrated":
// the working calibration usually comes from config.ini and predates the run.
AxisMapView live_yaw, live_pitch;
};
// Worst-case encoder tracking error from the last DIAG run, per axis, for the

View File

@ -16,6 +16,9 @@ int main(int argc, char* argv[]) {
desc.add_options()
("help,h", "show this help")
("config,c", po::value<std::string>(), "path to config.ini")
("hardware", po::value<std::string>(),
"path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, "
"or one beside config.ini)")
("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)")
("start,s", po::bool_switch(), "start capture automatically")
("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)")
@ -26,6 +29,9 @@ int main(int argc, char* argv[]) {
"use a simulated ambient temp/humidity sensor (implies enable_env)")
("tui", po::bool_switch(), "show the full-screen terminal dashboard")
("no-tui", po::bool_switch(), "force the headless line console (overrides config)")
("degraded", po::bool_switch(),
"start even though hardware.ini declares a subsystem this rig does not have "
"(field triage; says so loudly in the log)")
("no-lock", po::bool_switch(),
"skip the single-instance lock (dev only: for a second mock-only instance)")
("lock-file", po::value<std::string>(), "path to the single-instance lockfile")
@ -56,9 +62,20 @@ int main(int argc, char* argv[]) {
return 1;
}
// hardware.ini describes the machine itself and is optional: a tower that has
// not been migrated yet simply has none, and everything comes from config.ini
// exactly as before. An explicit --hardware that does not exist IS an error,
// though - it means someone expected an assembly description to be in force.
const std::string cli_hw = vm.count("hardware") ? vm["hardware"].as<std::string>() : "";
auto hw_path = paths::resolveHardwarePath(cli_hw, *cfg_path);
if (!cli_hw.empty() && !hw_path) {
std::cerr << "No hardware.ini at --hardware " << cli_hw << "\n";
return 1;
}
AppConfig cfg;
try {
cfg = ConfigLoader::loadFromFile(*cfg_path);
cfg = ConfigLoader::loadFromFile(*cfg_path, hw_path.value_or(""));
} catch (const std::exception& e) {
std::cerr << "Config error (" << *cfg_path << "): " << e.what() << "\n";
return 1;
@ -89,6 +106,7 @@ int main(int argc, char* argv[]) {
if (vm["mock-camera"].as<bool>()) opts.mock_camera = true;
if (vm["mock-serial"].as<bool>()) opts.mock_serial = true;
if (vm["mock-env"].as<bool>()) opts.mock_env = true;
opts.degraded = vm["degraded"].as<bool>();
if (vm["tui"].as<bool>()) opts.use_tui = true;
if (vm["no-tui"].as<bool>()) opts.use_tui = false; // --no-tui wins
if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>();

199
scripts/export_camera_profile.py Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
export_camera_profile.py - build a camera profile XML on the tower, headlessly.
The documented way to make a profile is the VimbaX Viewer: configure the camera
in a GUI, save the settings XML. On a tower that is awkward - the machine is
headless and reached over ssh - and it is impossible during an unattended
install. This does the same job through the SDK's Python API:
apply a recipe feature by feature, let the CAMERA validate each write,
then export what it actually accepted.
That keeps the property the Viewer workflow is there to provide. GenICam
validity is stateful - AcquisitionFrameRate is only writable once
AcquisitionFrameRateEnable is set, Width's maximum depends on binning - so a
hand-written XML can be individually plausible and collectively wrong. Here
every value is written to the real camera in dependency order and read back, and
anything the camera refused is reported rather than silently kept.
Usage, on the tower:
GENICAM_GENTL64_PATH=/opt/VimbaX/cti ~/.venvs/vmbpy/bin/python \
scripts/export_camera_profile.py --profile standard --out config/camera_profiles/rgb
... --dry-run report what would change, write nothing
... --camera DEV_xxx pick a camera explicitly
Needs vmbpy, which ships with the SDK:
python3 -m venv ~/.venvs/vmbpy
~/.venvs/vmbpy/bin/pip install /opt/VimbaX/api/python/vmbpy-*.whl
"""
import argparse
import os
import sys
try:
import vmbpy
except ImportError:
sys.exit("vmbpy not installed - see the header of this file")
# Recipes are ordered: binning changes the sensor geometry, and the throughput
# limit caps the frame rate, so both come before the rate is set. A feature the
# camera does not have is reported and skipped, not fatal: the fleet runs several
# Alvium generations and their feature vocabularies differ.
RECIPES = {
# Daylight. 2x2 binning is not optional at 10 Hz: unbinned RGB8 on a 16 MP
# sensor is a 48 MB frame, which is both over the USB ceiling in
# known-issues #19 and far too slow to reach 10 fps under any throughput
# limit this camera offers.
"standard": [
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 10.0),
("ExposureAuto", "Continuous"),
("ExposureAutoMin", 200.0),
("ExposureAutoMax", 20000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 24.0),
("IntensityAutoPrecedence", "MinimizeBlur"),
("DeviceIndicatorMode", "Active"),
("DeviceIndicatorLuminance", 10),
],
# Night: 1 Hz, long exposures, more gain allowed, noise over motion blur,
# and the status LED off so it cannot contaminate a long exposure.
"night": [
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 1.0),
("ExposureAuto", "Continuous"),
("ExposureAutoMin", 1000.0),
("ExposureAutoMax", 500000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 48.0),
("IntensityAutoPrecedence", "MinimizeNoise"),
("DeviceIndicatorMode", "Inactive"),
("DeviceIndicatorLuminance", 0),
],
}
def apply_feature(cam, name, value, dry_run):
try:
feat = cam.get_feature_by_name(name)
except Exception:
return ("missing", name, None, value)
try:
before = feat.get()
except Exception:
before = None
# Clamp a numeric value the camera cannot reach, rather than letting the
# write fail and leaving whatever was there before. Without this, exporting
# 'standard' right after 'night' wrote a standard profile running at the
# night rate: the 10 fps write was refused and 1 fps simply stayed. A
# refused write is not a no-op, it is a silently inherited value.
clamped = False
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
lo, hi = feat.get_range()
if value > hi:
value, clamped = (type(value)(hi) if not isinstance(value, int) else int(hi)), True
elif value < lo:
value, clamped = (type(value)(lo) if not isinstance(value, int) else int(lo)), True
except Exception:
pass
if dry_run:
return ("would-clamp" if clamped else "would-set", name, before, value)
try:
feat.set(value)
except Exception as exc:
return ("refused", name, before, f"{value} ({type(exc).__name__}: {exc})")
try:
after = feat.get()
except Exception:
after = value
return ("clamped" if clamped else "set", name, before, after)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--profile", default="standard", choices=sorted(RECIPES))
ap.add_argument("--out", default="config/camera_profiles/rgb",
help="directory to write <profile>.xml into")
ap.add_argument("--camera", default=None, help="camera id (default: the first real one)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if not os.environ.get("GENICAM_GENTL64_PATH"):
for cti in ("/opt/VimbaX/cti",):
if os.path.isdir(cti):
os.environ["GENICAM_GENTL64_PATH"] = cti
break
with vmbpy.VmbSystem.get_instance() as vmb:
cams = vmb.get_all_cameras()
if args.camera:
cams = [c for c in cams if c.get_id() == args.camera]
else:
# The SDK's camera simulator transport layer is enabled by default
# and offers DEV_Cam1..3. Exporting a profile from one of those
# would produce a file that configures nothing real.
cams = [c for c in cams if not c.get_id().startswith("DEV_Cam")]
if not cams:
sys.exit("no real camera found")
cam = cams[0]
with cam:
print(f"camera : {cam.get_id()} {cam.get_model()}")
print(f"profile : {args.profile}{' (dry run)' if args.dry_run else ''}")
refused = []
for name, value in RECIPES[args.profile]:
state, n, before, after = apply_feature(cam, name, value, args.dry_run)
mark = {"set": " ", "would-set": " ?", "missing": " !", "refused": " X",
"clamped": " ~", "would-clamp": " ~"}[state]
note = " (clamped: the camera cannot reach the recipe's value)" \
if state in ("clamped", "would-clamp") else ""
print(f"{mark} {n:28} {before} -> {after}{note}")
if state in ("missing", "refused"):
refused.append(n)
# What the settings actually cost per frame - the number that decides
# whether this profile can be delivered over USB at all.
try:
w = cam.get_feature_by_name("Width").get()
h = cam.get_feature_by_name("Height").get()
fmt = cam.get_feature_by_name("PixelFormat").get()
bpp = 3 if str(fmt) in ("RGB8", "BGR8") else 1
rate = cam.get_feature_by_name("AcquisitionFrameRate").get()
mb = w * h * bpp / 1e6
print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame "
f"{rate:.2f} fps = {mb * rate:.0f} MB/s")
if mb > 20:
print("WARNING : over the ~20 MB/frame ceiling in known-issues #19")
except Exception:
pass
if args.dry_run:
return 0
os.makedirs(args.out, exist_ok=True)
path = os.path.join(args.out, f"{args.profile}.xml")
cam.save_settings(path, vmbpy.PersistType.All)
print(f"\nwrote : {path}")
if refused:
print(f"NOTE : not applied: {', '.join(refused)}")
print(" the XML records what the camera accepted, not the recipe")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -9,6 +9,11 @@
#include "fgc/HelpText.h"
#include "fgc/Paths.h"
#include "fgc/CameraProfile.h"
#include "fgc/AxisSpeed.h"
#include "fgc/FirmwareConfig.h"
#include "fgc/HardwareChecks.h"
#include "fgc/HardwarePresence.h"
#include "fgc/TmcCurrent.h"
#include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h"
@ -40,6 +45,7 @@
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <memory>
@ -81,6 +87,30 @@ long long nowEpochMs() {
.count();
}
// "2026-09-11T14:32Z" - UTC, minute resolution. Stamped into the `;prev` comments
// saveMotorGeometry leaves behind, where the point is telling two calibrations
// apart later, not sub-second precision. UTC because towers are read remotely and
// a local-time stamp says nothing without also knowing the host's zone.
std::string utcStampNow() {
const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm tm{};
#if defined(_WIN32)
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
// 64, not 32, purely to keep the compiler quiet. The real output is always 17
// characters, but the tm fields are plain ints, so GCC cannot rule out a
// nonsense clock and computes a worst case of 61 bytes - warning on every
// build. snprintf truncates rather than overflows either way, so this was
// never a safety problem; a recurring warning that everyone learns to ignore
// is the actual cost.
char b[64];
std::snprintf(b, sizeof(b), "%04d-%02d-%02dT%02d:%02dZ", tm.tm_year + 1900, tm.tm_mon + 1,
tm.tm_mday, tm.tm_hour, tm.tm_min);
return std::string(b);
}
// True if `dir` can be created and a probe file written there. A successful
// create_directories alone isn't enough (the dir may exist read-only), so we
// actually write+remove a probe file.
@ -218,7 +248,13 @@ struct Application::Impl {
CalibResultView last_calib_view;
std::vector<std::string> last_calib_summary;
long long last_calib_ts = 0; // epoch ms
// Motor-current registers computed once from hardware.ini, sent to the
// firmware before every home, and checked against the DUMP echo afterwards.
TmcCurrentPlan yaw_current_, pitch_current_;
bool calib_save_pending_ = false; // awaiting y/n to persist to config
// Which command produced the pending values, recorded into the `;prev`
// comment when they are saved so the file says what superseded what.
std::string calib_save_source_;
// Hardware self-test (`test` command) results, for the activity strip.
TestResultView last_test_view;
@ -270,7 +306,7 @@ struct Application::Impl {
std::unique_ptr<IMotorController> makeMotor() {
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
if (mock) return std::make_unique<MockMotorController>();
if (mock) return std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
}
@ -318,12 +354,33 @@ struct Application::Impl {
std::chrono::milliseconds(cfg.env.period_ms));
}
std::unique_ptr<ICameraSource> makeCamera() {
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
// Does this run drive a real camera? Asked in two places - makeCamera() and
// the startup sequence, which must agree: profiles are meaningful only to
// VimbaCameraSource, so loading them for a mock run demands settings XMLs
// that a tower being brought up one subsystem at a time does not have yet.
//
// enable_camera substitutes the mock source rather than leaving `camera`
// null, because the capture path dereferences it in a dozen places. The
// result is a run with no camera hardware and no Vimba dependency, which is
// what the flag is for; it is not a silent no-op, and says so at startup.
bool realCameraWanted() const {
#if !FGC_WITH_VIMBA
if (!mock) {
return false;
#else
if (!cfg.features.enable_camera) return false;
return !opts.mock_camera.value_or(cfg.features.mock_camera);
#endif
}
std::unique_ptr<ICameraSource> makeCamera() {
bool mock = !realCameraWanted();
if (mock && !cfg.features.enable_camera) {
LOG_WARN << "camera disabled ([Features] enable_camera = false); using a mock source "
"- no images come from hardware in this run";
}
#if !FGC_WITH_VIMBA
if (!opts.mock_camera.value_or(cfg.features.mock_camera) && cfg.features.enable_camera) {
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
mock = true;
}
#endif
const auto active = cfg.camera.active();
@ -552,7 +609,7 @@ struct Application::Impl {
return;
}
if (!imu) {
LOG_WARN << "no IMU (needs [Features] enable_imu, or mock_imu)";
LOG_WARN << "no IMU (declare [Hardware] imu, or use --mock-imu)";
return;
}
@ -607,7 +664,9 @@ struct Application::Impl {
const bool mock_serial = opts.mock_serial.value_or(cfg.features.mock_serial);
s.header.tower = cfg.general.tower_name;
s.header.build = "dev";
// Set by CMake from $GIT_REV, which deploy.sh passes in; "unknown" for a
// plain local build. This is the TUI header's build id.
s.header.build = FGC_GIT_REV;
s.header.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start_time).count();
s.header.live = !mock_serial;
@ -858,6 +917,9 @@ struct Application::Impl {
// --- Activity strip + last calibration ---
s.calib = last_calib_view;
s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present;
// The map in force right now, whether or not anything was fitted today.
s.calib.live_yaw = {cfg.geometry.yaw.counts_per_deg, cfg.geometry.yaw.deg_at_center};
s.calib.live_pitch = {cfg.geometry.pitch.counts_per_deg, cfg.geometry.pitch.deg_at_center};
s.diag = last_diag_view;
s.test = last_test_view;
fillActivity(s);
@ -942,7 +1004,12 @@ struct Application::Impl {
// Pending yes/no: persist the just-applied calibration as the new default?
if (calib_save_pending_)
a.prompt = "Save the new [Motor] calibration to config as the default? (y / n)";
// Name the source: a rough derivation and a landmark fit both arrive
// here, and they do not deserve the same confidence.
a.prompt = "Save the new [Motor] calibration (" +
(calib_save_source_.empty() ? std::string("calibration")
: calib_save_source_) +
") to config as the default? (y / n)";
}
void publishSnapshot() {
@ -986,12 +1053,32 @@ struct Application::Impl {
} clear_homing{homing_active_};
motor->sendCommand("ENABLE Y");
motor->sendCommand("ENABLE P");
// Only address axes this tower actually has. A yaw-only rig answers
// "ERR axis not present" to every pitch command, which arrives in the log
// as a row of warnings at every startup - noise that trains an operator to
// ignore exactly the channel that reports real faults.
if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("ENABLE P");
// Tell the firmware where to anchor the homed midpoint and how much
// clearance to keep from each endstop, BEFORE homing: finishHoming() uses
// the centre count to re-origin, so a late send would apply to the next
// home, not this one.
sendAnchorConfig();
// Confirm the current landed BEFORE driving into anything. This is the
// only check standing between a stale firmware and an endstop that cannot
// survive full torque.
// Only when a reduction is actually depended on. An axis running at one
// current for everything has nothing to lose if the command went astray,
// and the check costs a DUMP round trip whose output floods the log.
if (cfg.drivetrain.yaw.reducesForHoming() || cfg.drivetrain.pitch.reducesForHoming()) {
if (auto dd = freshDump(std::chrono::seconds(3))) {
if (!verifyCurrentAdoption(*dd)) return;
} else {
LOG_WARN << "no firmware dump before homing; cannot confirm the motor "
"current was adopted";
}
}
motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking
auto allReady = [](const MotorTelemetry& t) {
@ -1012,7 +1099,12 @@ struct Application::Impl {
// Wait for completion (both axes READY again) or the homing timeout.
bool homed = false;
const auto deadline = std::chrono::steady_clock::now() + 65s;
// How long a full two-end search may take is a property of the drivetrain,
// not of this program: a high gear ratio homing at a deliberately gentle
// speed takes minutes, and a fixed 65 s ceiling reported those towers as
// failed while the firmware was still searching perfectly happily.
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(cfg.homing_timeout_s);
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(250ms);
drainAndDiscardCommands();
@ -1034,24 +1126,270 @@ struct Application::Impl {
motor->sendCommand("STOP ALL");
return;
}
// Re-anchor the yaw zero to an endstop now that the homed limits are known.
if (homed) applyHomedTravel();
// Production move speeds for subsequent MOVE commands.
motor->sendCommand("SPEED Y 50000");
motor->sendCommand("SPEED P 150000");
// Re-anchor the yaw zero to an endstop now that the homed limits are known,
// and take the chance to check the degree scale against the travel we just
// measured. Derivation first: applyHomedTravel() converts the soft limits
// through the map, so it wants the corrected scale.
if (homed) {
using namespace std::chrono_literals;
if (auto dd = freshDump(3s)) {
deriveCountsPerDeg(*dd);
applyHomedTravel(&*dd);
} else {
LOG_WARN << "no firmware dump after homing; degree limits and the derived "
"counts_per_deg both skipped";
}
}
// Production move speeds for subsequent MOVE commands, converted from the
// declared deg/s. Sent here rather than earlier because deriveCountsPerDeg()
// above has just refreshed the scale this conversion depends on.
sendProductionSpeeds();
LOG_INFO << "Gimbal init finished";
}
// Translate each axis's declared deg/s into the VMAX this drivetrain needs,
// and tell the firmware.
//
// The speed is declared as a physical quantity because a register value is
// not portable: the same VMAX is 21.8 deg/s on the Delta fleet and 6.0 deg/s
// on the IOTA, and both were carrying the same literal. What is logged is
// what the axis will actually do, not what was asked for - the two differ by
// the integer quantisation, and by more than that when a request is clamped.
void sendProductionSpeeds() {
auto one = [&](const char* name, char code, const AxisMap& map,
const AxisDrivetrainConfig& d) {
if (!cfg.hardware.hasAxis(name)) return;
const auto p = planAxisSpeed(d.scan_speed_deg_s, map.counts_per_deg);
if (!p.ok) {
// Leave the firmware on its compile-time default rather than
// commanding a speed derived from a scale we do not trust.
LOG_WARN << name << " speed: not set -- " << p.error
<< "; the firmware keeps its built-in default";
return;
}
motor->sendCommand(std::string("SPEED ") + code + " " + std::to_string(p.vmax));
if (p.clamped)
LOG_WARN << name << " speed: " << d.scan_speed_deg_s
<< " deg/s exceeds what VMAX can express on this drivetrain; clamped to "
<< p.actual_deg_s << " deg/s";
LOG_INFO << name << " speed: " << p.actual_deg_s << " deg/s (VMAX " << p.vmax
<< ") -- " << map.counts_per_deg << " counts/deg";
};
one("yaw", 'Y', cfg.geometry.yaw, cfg.drivetrain.yaw);
one("pitch", 'P', cfg.geometry.pitch, cfg.drivetrain.pitch);
}
// Build what the host can answer for one axis, for the config handshake.
//
// Speeds are converted here rather than declared as VMAX, for the reason in
// AxisSpeed.h. Homing and centring happen BEFORE the first home, so they
// cannot use the counts_per_deg that homing derives - they use the scale the
// declaration implies (steps_per_rev / travel_deg), which on the IOTA agrees
// with the measured one to 0.25%.
AxisConfigSource axisConfigSource(const char* axis, const AxisMap& map,
const AxisDrivetrainConfig& d, const TmcCurrentPlan& cur,
long soft_margin) {
AxisConfigSource s;
s.have_current = cur.ok;
s.global_scaler = cur.global_scaler;
s.run_irun = cur.run_irun;
s.homing_irun = cur.homing_irun;
s.center_count = map.center_count;
s.soft_margin = soft_margin;
s.have_travel = d.steps_per_rev > 0;
s.steps_per_rev = d.steps_per_rev;
s.have_deviation = d.enc_deviation > 0;
s.enc_deviation = d.enc_deviation;
s.have_wiring = d.wiring_declared;
s.sw_mode = d.sw_mode;
s.has_encoder = d.has_encoder;
// The scale to convert through. The firmware asks for all three speeds at
// BOOT, before anything has been homed, so the live calibration is often
// still AxisMap's uncalibrated default - fall back to the one the
// declaration implies. Homing supersedes it: sendProductionSpeeds() re-sends
// the production speed against the measured scale once it exists.
const double live = std::fabs(map.counts_per_deg);
const double cpd = live >= kMinPlausibleCountsPerDeg ? map.counts_per_deg
: d.preHomeCountsPerDeg();
auto vmax = [&](double deg_s) -> long {
if (deg_s <= 0.0) return 0; // undeclared: firmware default stands
const auto p = planAxisSpeed(deg_s, cpd);
if (!p.ok) {
LOG_WARN << axis << " " << deg_s << " deg/s could not be converted: " << p.error;
return 0;
}
return p.vmax;
};
s.normal_vmax = vmax(d.scan_speed_deg_s);
s.homing_vmax = vmax(d.homing_speed_deg_s);
s.center_vmax = vmax(d.center_speed_deg_s);
return s;
}
// Answer any NEED lines the firmware has emitted.
//
// The firmware stays in NEEDCFG and moves nothing until its required set is
// complete, so an unanswered key is a startup failure rather than a warning:
// it is named here, loudly, because the fix is in hardware.ini and nothing
// else will make the gimbal move.
void answerFirmwareNeeds() {
for (const auto& line : motor->takeNeedLines()) {
const auto need = parseNeedLine(line);
if (!need) {
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;
}
}
// 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.
void planMotorCurrents() {
auto plan = [&](const char* name, const AxisDrivetrainConfig& d, TmcCurrentPlan& out) {
if (!d.declared || d.run_current_ma <= 0.0) return;
out = planTmcCurrent(d.r_sense_ohm, d.run_current_ma, d.homing_current_ma);
for (const auto& w : out.warnings) LOG_WARN << name << " motor current: " << w;
if (!out.ok) {
LOG_ERROR << name << " motor current could not be computed; the firmware "
"will keep its built-in defaults";
return;
}
// Log what the registers actually deliver, not what was asked for.
LOG_INFO << name << " motor current: run " << std::lround(out.run_ma)
<< " mA (GLOBALSCALER " << out.global_scaler << ", IRUN " << out.run_irun
<< "), homing " << std::lround(out.homing_ma) << " mA (IRUN "
<< out.homing_irun << ") -- R_sense " << d.r_sense_ohm
<< " ohm from hardware.ini [Motor." << name << ']';
};
plan("yaw", cfg.drivetrain.yaw, yaw_current_);
plan("pitch", cfg.drivetrain.pitch, pitch_current_);
}
// Did the firmware actually adopt the current we sent? GLOBALSCALER and
// IHOLD_IRUN are write-only on the TMC5160, so the DUMP echo is the only
// channel - and sendCommand() is fire-and-forget, so an 'ERR unknown command'
// from an older firmware arrives as an uncorrelated warning we cannot pin to
// this command. Returns false only when a reduction was REQUIRED and is
// demonstrably absent.
bool verifyCurrentAdoption(const DumpData& dd) {
bool safe = true;
auto check = [&](const char* name, char axis, const AxisDrivetrainConfig& d,
const TmcCurrentPlan& want) {
if (!want.ok) return;
const DumpAxis* ax = nullptr;
for (const auto& a : dd.axes)
if (a.axis == axis) ax = &a;
if (!ax) return;
const bool absent = (ax->run_irun < 0);
const bool wrong = !absent && (ax->global_scaler != want.global_scaler ||
ax->run_irun != want.run_irun ||
ax->homing_irun != want.homing_irun);
if (!absent && !wrong) return;
std::ostringstream detail;
if (absent)
detail << "the firmware does not report a motor current at all, so it "
"predates the CURRENT command";
else
detail << "the firmware reports GLOBALSCALER " << ax->global_scaler << ", IRUN "
<< ax->run_irun << '/' << ax->homing_irun << " but was sent "
<< want.global_scaler << ", IRUN " << want.run_irun << '/'
<< want.homing_irun;
if (d.reducesForHoming()) {
// The reduction is load-bearing: this tower's endstop yields to
// full torque. Homing anyway is how the stop gets destroyed.
LOG_ERROR << name << " motor current was NOT adopted -- " << detail.str()
<< ". Refusing to home: this axis declares a reduced homing "
"current because its endstop cannot survive full torque. "
"Flash the firmware, or remove homing_current_ma from "
"hardware.ini to accept the risk deliberately.";
safe = false;
} else {
LOG_WARN << name << " motor current was not adopted -- " << detail.str()
<< ". Continuing: this axis asks for no reduction, so homing is "
"unaffected.";
}
};
check("yaw", 'Y', cfg.drivetrain.yaw, yaw_current_);
check("pitch", 'P', cfg.drivetrain.pitch, pitch_current_);
return safe;
}
// Tell the firmware the anchor point and endstop clearance for each axis.
// The centre count is what finishHoming() re-origins onto, so all counts stay
// positive and reproducible across power cycles; the margin is what
// clampTarget() keeps clear of each hard limit.
void sendAnchorConfig() {
motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count));
motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts));
if (cfg.hardware.hasAxis("pitch")) {
motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts));
}
// Motor current belongs here too: it governs homing torque, so it has to
// land before the search, not after it.
auto sendCurrent = [&](char axis, const TmcCurrentPlan& p) {
if (!p.ok) return;
motor->sendCommand(std::string("CURRENT ") + axis + ' ' +
std::to_string(p.global_scaler) + ' ' +
std::to_string(p.run_irun) + ' ' +
std::to_string(p.homing_irun));
};
sendCurrent('Y', yaw_current_);
sendCurrent('P', pitch_current_);
}
// Derive each axis's reachable degree window from the freshly homed soft
// limits. These are deliberately NOT configurable: the firmware's limits are
@ -1064,24 +1402,93 @@ struct Application::Impl {
// not take effect - firmware predating it answers "ERR unknown command" and
// homes to its own compiled default, which would leave every published
// bearing silently wrong.
void applyHomedTravel() {
// Request a DUMP and wait briefly for a parseable one. Several callers need
// the firmware's own view of itself - the homed limits, and whether it adopted
// what we sent - so the round trip lives in one place.
// What this run actually has, for reconciliation against hardware.ini.
// Called once at startup, after every subsystem has been constructed and
// started, because "found" can only mean "came up", not "was configured".
FoundHardware observeHardware(std::chrono::milliseconds wait) {
using namespace std::chrono_literals;
FoundHardware f;
// The homed limits live in the firmware DUMP; pull a fresh one and wait
// briefly for it to arrive.
// Axes come from telemetry, which takes a moment to arrive. Yaw reaching
// any real state is the signal the link is alive; until then a
// default-constructed snapshot would report every axis missing.
MotorTelemetry t = motor->telemetry();
const auto deadline = std::chrono::steady_clock::now() + wait;
while (running && t.yaw.state == AxisState::Unknown &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms);
t = motor->telemetry();
}
f.axes_known = t.yaw.state != AxisState::Unknown;
if (f.axes_known) {
f.axes.push_back("yaw"); // any well-formed ST line carries Y:
if (t.pitch_present) f.axes.push_back("pitch");
}
// A null source is the "not fitted / not enabled" case; makeImu() and
// makeEnv() already log why.
f.imu = imu != nullptr;
f.env = env != nullptr;
for (const auto* c : cfg.camera.active()) f.cameras.push_back(c->name);
f.cameras_opened = camera ? camera->cameraCount() : -1;
return f;
}
// Compare the rig against its declaration and act on the difference.
// Returns false when the run must not continue.
bool reconcileHardware() {
using namespace std::chrono_literals;
if (!cfg.hardware.declared) return true; // nothing declared, nothing to check
const PresenceReport report = reconcilePresence(cfg.hardware, observeHardware(3s));
for (const auto& line : formatPresence(report)) LOG_WARN << "hardware: " << line;
const PresenceVerdict v =
presenceVerdict(report, cfg.features.strict_presence, opts.degraded);
switch (v.action) {
case PresenceAction::Proceed:
LOG_INFO << "hardware: declared assembly confirmed (" << cfg.hardware.axesLabel()
<< ")";
return true;
case PresenceAction::WarnOnly:
LOG_WARN << "hardware: " << v.reason;
return true;
case PresenceAction::Refuse:
LOG_ERROR << "hardware: " << v.reason;
return false;
}
return true;
}
std::optional<DumpData> freshDump(std::chrono::milliseconds wait) {
using namespace std::chrono_literals;
motor->sendCommand("DUMP");
DumpData dd;
const auto deadline = std::chrono::steady_clock::now() + 3s;
const auto deadline = std::chrono::steady_clock::now() + wait;
while (running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms);
publishSnapshot();
dd = parseDump(motor->lastDump());
if (dd.valid) break;
DumpData dd = parseDump(motor->lastDump());
if (dd.valid) return dd;
}
if (!dd.valid) {
return std::nullopt;
}
void applyHomedTravel(const DumpData* pre = nullptr) {
using namespace std::chrono_literals;
std::optional<DumpData> got;
if (!pre) {
got = freshDump(3s);
if (!got) {
LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits";
return;
}
}
const DumpData& dd = pre ? *pre : *got;
bool any = false;
for (const auto& a : dd.axes) {
@ -1092,6 +1499,15 @@ struct Application::Impl {
const long want_margin =
is_yaw ? cfg.yaw_soft_margin_counts : cfg.pitch_soft_margin_counts;
// The centre is arbitrary, but it has to be big enough that the whole
// travel stays positive: the range is centre +/- half the travel, so
// a centre below half-travel puts the negative end under zero. The
// 500000 default was sized for the Delta's ~89k half-travel and does
// not survive a gear ratio 7x higher.
const auto fits = checkTravelFitsPositive(name, map.center_count,
(a.lim_pos - a.lim_neg) / 2);
if (fits.ran && !fits.pass) LOG_WARN << fits.detail;
// One encoder quantum of slack: ENC_CONST makes X_ENC advance in 12.8
// microstep steps, so the midpoint can land a count or so off.
const long mid = a.lim_neg + (a.lim_pos - a.lim_neg) / 2;
@ -1254,6 +1670,10 @@ struct Application::Impl {
y.deg_at_center = r.deg_at_center;
applyYawMapChange();
// The landmark wizard and a bare `gimbal heading fit` land here alike; name
// whichever it was before the flag is cleared.
calib_save_source_ = heading_setup_active_ ? "gimbal heading setup"
: "gimbal heading fit";
heading_setup_active_ = false;
// Offer to persist, through the same activity-strip prompt the pitch setup
// uses - the operator answers y/n without leaving the dashboard.
@ -1337,6 +1757,71 @@ struct Application::Impl {
return static_cast<bool>(iss >> a >> b);
}
// Cross-check the degree scale against the travel homing just measured, and
// bootstrap it when it is obviously wrong.
//
// Called ONLY from the homing path. applyHomedTravel() runs after every
// heading fit and offset too, and deriving there would overwrite a landmark
// calibration with this rougher figure on every tweak.
void deriveCountsPerDeg(const DumpData& dd) {
struct AxisRef {
const char* name;
char axis;
const AxisDrivetrainConfig& drive;
AxisMap& map;
};
AxisRef axes[] = {
{"yaw", 'Y', cfg.drivetrain.yaw, cfg.geometry.yaw},
{"pitch", 'P', cfg.drivetrain.pitch, cfg.geometry.pitch},
};
bool changed = false;
for (auto& a : axes) {
if (a.drive.travel_deg <= 0.0) continue; // nothing declared to divide by
// Measured travel from THIS dump. Taking it from motor->lastDump()
// instead would read whatever dump happened to be cached - which,
// before applyHomedTravel() fetches a fresh one, is the pre-homing
// dump whose limits are still zero, so the derivation would skip in
// silence on every single home.
long counts = 0;
for (const auto& ax : dd.axes)
if (ax.axis == a.axis) counts = std::labs(ax.lim_pos - ax.lim_neg);
if (counts <= 0) continue; // that axis did not home
// The angle is unsigned; which way the axis counts is a property of
// the map, so carry the existing sign rather than inventing one.
const double derived =
std::copysign(counts / a.drive.travel_deg, a.map.counts_per_deg);
const double current = a.map.counts_per_deg;
const double ratio =
std::fabs(current) > 1e-9 ? std::fabs(derived / current) : 1e9;
// Only bootstrap a scale that is obviously wrong. Within a factor of
// two this is a health check, not a correction: a landmark fit is a
// better measurement than this one and must not be clobbered by a
// routine re-home.
if (ratio > 2.0 || ratio < 0.5) {
LOG_WARN << a.name << " counts_per_deg derived from homing: " << derived
<< " (was " << current << ") = " << counts << " counts / "
<< a.drive.travel_deg << " deg of travel declared in hardware.ini ["
<< "Motor." << a.name << "] travel_deg. That angle is a NAMEPLATE "
"figure: every bearing carries its error until it is measured. "
"Run 'gimbal heading setup' to supersede this with a landmark fit.";
a.map.counts_per_deg = derived;
changed = true;
} else {
LOG_INFO << a.name << " counts_per_deg " << current << " agrees with the homed "
<< "travel (" << counts << " counts / " << a.drive.travel_deg
<< " deg = " << derived << "); leaving the calibration alone";
}
}
if (changed && !opts.config_path.empty()) {
calib_save_source_ = "homed travel / hardware.ini travel_deg (nameplate)";
calib_save_pending_ = true;
}
}
// Homed physical travel (counts) for an axis: the endstop-to-endstop span from
// the last firmware dump. Returns 0 if no dump has been captured yet (caller
// should request one). Deliberately does NOT fall back to the configured degree
@ -1359,7 +1844,8 @@ struct Application::Impl {
while (iss >> t) tok.push_back(t); // tok[0] == "gimbal"
if (tok.size() < 2) {
LOG_WARN << "usage: gimbal <move|steps|nudge|jog|heading|pitch|home|stop|reset|"
"enable|disable|speed|setpos|status|dump|raw> (self-test: 'test')";
"enable|disable|speed|current|setpos|status|dump|raw> "
"(self-test: 'test')";
return;
}
std::string sub = tok[1];
@ -1456,7 +1942,8 @@ struct Application::Impl {
// home rather than a fire-and-forget HOME that skips the re-anchor.
runInitSequence();
} else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" ||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") {
sub == "disable" || sub == "speed" || sub == "setpos" ||
sub == "status" || sub == "current") {
// Passthrough to the (case-insensitive) firmware verb + args.
std::string fw = sub;
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch));
@ -1472,7 +1959,7 @@ struct Application::Impl {
// gravity, and nothing else on the tower can measure it. Yaw does not -
// `gimbal heading setup` uses landmarks instead.
if (!imu) {
LOG_WARN << "gimbal pitch setup requires the IMU (set [Features] enable_imu); "
LOG_WARN << "gimbal pitch setup requires the IMU (declare [Hardware] imu); "
"yaw does not - use 'gimbal heading setup'";
return;
}
@ -1490,12 +1977,19 @@ struct Application::Impl {
}
// Persist the live (just-calibrated) [Motor] geometry back into config.ini.
// Superseded values are kept above their key as `;prev` comments, stamped with
// when and what replaced them, so a calibration that turns out worse than the
// one it replaced can be reverted by uncommenting.
void saveCalibration() {
if (!calib_save_pending_) return;
calib_save_pending_ = false;
const std::string provenance =
utcStampNow() + ", " +
(calib_save_source_.empty() ? std::string("calibration") : calib_save_source_);
if (saveMotorGeometry(opts.config_path, cfg.geometry, cfg.yaw_soft_margin_counts,
cfg.pitch_soft_margin_counts))
LOG_INFO << "[Motor] calibration saved as the new default in " << opts.config_path;
cfg.pitch_soft_margin_counts, provenance))
LOG_INFO << "[Motor] calibration saved as the new default in " << opts.config_path
<< " (previous values kept as ';prev' comments)";
else
LOG_WARN << "could not write [Motor] calibration to " << opts.config_path;
}
@ -1613,7 +2107,7 @@ struct Application::Impl {
}
// IMU leaves need an IMU; drop them (with a warning) if absent.
if ((mask & T_NEEDS_IMU) && !imu) {
LOG_WARN << "test: IMU tests skipped (set [Features] enable_imu)";
LOG_WARN << "test: IMU tests skipped (declare [Hardware] imu)";
mask &= ~T_NEEDS_IMU;
}
// Motor leaves need a homed gimbal.
@ -1634,8 +2128,22 @@ struct Application::Impl {
stopCapture(); // free the serial link; don't fight the routine's moves
LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
// What `test hardware` needs beyond the motor and IMU: the declaration to
// check against, and the subsystems that answer for themselves.
HardwareTestContext hw;
hw.declared = &cfg.hardware;
hw.drive = &cfg.drivetrain;
hw.env = env.get();
hw.camera = camera.get();
for (const auto* c : cfg.camera.active()) hw.configured_cameras.push_back(c->name);
hw.i2c_device = cfg.env.i2c_device;
hw.geo_yaw = cfg.geometry.yaw;
hw.geo_pitch = cfg.geometry.pitch;
hw.usbfs_min_mb = cfg.test.usbfs_min_mb;
hw.lpm_path = cfg.test.usb3_lpm_path;
tester = std::make_unique<TestRunner>(*motor, imu.get(), cfg.geometry, mask, profile,
cfg.paths.output_dir);
cfg.paths.output_dir, std::move(hw));
tester->start();
}
@ -1723,6 +2231,7 @@ struct Application::Impl {
last_calib_view = v;
last_calib_ts = nowEpochMs();
// Offer to persist it as the new default (answered via the activity strip).
calib_save_source_ = "gimbal pitch setup";
if (rep.valid && !opts.config_path.empty()) calib_save_pending_ = true;
}
}
@ -1954,7 +2463,15 @@ struct Application::Impl {
if (Logger::categories())
LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories());
loadProfiles(); // before makeCamera(): the profile is applied at start()
// Before makeCamera(): the profile is applied at start(). Skipped entirely
// for a mock or disabled camera - a settings XML configures a physical
// sensor and nothing else, so requiring one would block bringing a tower
// up in phases (docs/tower-installation.md section 10).
if (realCameraWanted()) {
loadProfiles();
} else {
LOG_INFO << "camera profiles not loaded: no real camera in this run";
}
channel = makeChannel();
motor = makeMotor();
@ -2037,10 +2554,26 @@ struct Application::Impl {
// the wait is the longest across them, not the first one's.
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
// Before the firmware is talked to: the registers are wanted by the very
// first sendAnchorConfig(), on both the --init and the no---init path.
planMotorCurrents();
motor->start();
if (imu) imu->start();
if (env) env->start();
camera->open();
// Everything is up, so "found" now means what it says. A tower that
// declares hardware it does not have stops here rather than quietly
// capturing a narrower panorama and reporting itself healthy.
if (!reconcileHardware()) {
camera->close();
if (imu) imu->stop();
if (env) env->stop();
motor->stop();
channel->disconnect();
return 3; // distinct from the config (1) and lock (2) exits
}
pipeline->start();
channel->publishStatus(0);
@ -2082,6 +2615,7 @@ struct Application::Impl {
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
while (running && !g_shutdown_requested.load()) {
drainCommands();
answerFirmwareNeeds(); // the firmware asks; hardware.ini answers
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick();
checkTrackingError(); // warn on live encoder-error excursions

73
src/core/AxisSpeed.cpp Normal file
View File

@ -0,0 +1,73 @@
#include "fgc/AxisSpeed.h"
#include <cmath>
namespace fgc {
namespace {
std::string num(double v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.3g", v);
return buf;
}
} // namespace
double vmaxToDegPerSec(long vmax, double counts_per_deg, double clock_hz) {
const double cpd = std::fabs(counts_per_deg);
if (cpd <= 0.0 || clock_hz <= 0.0) return 0.0;
return (static_cast<double>(vmax) * clock_hz / kTmcVmaxDiv) / cpd;
}
AxisSpeedPlan planAxisSpeed(double deg_per_s, double counts_per_deg, double clock_hz) {
AxisSpeedPlan p;
p.requested_deg_s = deg_per_s;
// A speed is a magnitude; the direction sense lives in counts_per_deg's sign
// and belongs to Geometry, not here.
const double cpd = std::fabs(counts_per_deg);
if (deg_per_s <= 0.0) {
p.error = "speed must be > 0 deg/s, got " + num(deg_per_s);
return p;
}
if (cpd < kMinPlausibleCountsPerDeg) {
p.error = "counts_per_deg is " + num(counts_per_deg) +
", which is not a calibrated drivetrain (an ungeared AS5047P alone reads 11.1) "
"-- calibrate the axis first ('gimbal heading setup'), or declare "
"[Motor.<axis>] travel_deg so homing can derive it";
return p;
}
if (clock_hz <= 0.0) {
p.error = "TMC clock must be > 0 Hz";
return p;
}
const double counts_per_s = deg_per_s * cpd;
const double exact = counts_per_s * kTmcVmaxDiv / clock_hz;
// Round rather than truncate: at low speeds truncation biases every axis slow,
// and at 1 count/s it would round to zero and stop the gimbal outright.
long vmax = std::lround(exact);
if (vmax > kTmcVmaxMax) {
vmax = kTmcVmaxMax;
p.clamped = true;
}
if (vmax < 1) {
// Anything that rounds below one unit cannot be commanded at all. Failing
// is right: silently substituting the slowest possible speed would look
// like a working axis that never finishes a sweep.
p.error = num(deg_per_s) + " deg/s is below what VMAX can express on this drivetrain (" +
num(cpd) + " counts/deg); the slowest commandable speed is " +
num(vmaxToDegPerSec(1, cpd, clock_hz)) + " deg/s";
return p;
}
p.ok = true;
p.vmax = vmax;
p.actual_deg_s = vmaxToDegPerSec(vmax, cpd, clock_hz);
return p;
}
} // namespace fgc

View File

@ -98,6 +98,33 @@ bool getBool(const std::map<std::string, std::string>& kv, const std::string& ke
throw std::runtime_error("Config key '" + key + "' must be a boolean, got '" + v + "'");
}
// "yaw, pitch" -> {"yaw","pitch"}. Lowercased and trimmed; empty entries dropped,
// so trailing commas and stray spacing are not errors an operator has to hunt.
std::vector<std::string> splitList(const std::string& s) {
std::vector<std::string> out;
std::string cur;
auto flush = [&] {
size_t a = cur.find_first_not_of(" \t");
if (a == std::string::npos) { cur.clear(); return; }
size_t b = cur.find_last_not_of(" \t");
std::string t = cur.substr(a, b - a + 1);
for (char& c : t) c = static_cast<char>(std::tolower((unsigned char)c));
if (!t.empty()) out.push_back(t);
cur.clear();
};
for (char c : s) {
if (c == ',') flush();
else cur += c;
}
flush();
return out;
}
std::string lowered(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower((unsigned char)c));
return s;
}
std::string envOr(const char* name, const std::string& fallback) {
const char* v = std::getenv(name);
return (v && *v) ? std::string(v) : fallback;
@ -210,6 +237,20 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval);
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
// [Board] / [Hardware]: normally from hardware.ini, but read from the same
// merged map, so a config.ini carrying them still works during migration.
cfg.board.model = get(kv, "Board.model", cfg.board.model);
cfg.board.firmware_env = get(kv, "Board.firmware_env", cfg.board.firmware_env);
cfg.hardware.axes = splitList(get(kv, "Hardware.axes"));
cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras"));
cfg.hardware.imu = lowered(get(kv, "Hardware.imu"));
cfg.hardware.env = lowered(get(kv, "Hardware.env"));
for (const auto& a : cfg.hardware.axes)
if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a +
"' (expected yaw and/or pitch)");
cfg.network.broker_ip = get(kv, "Network.zkms_server_ip", cfg.network.broker_ip);
cfg.network.mqtt_port = getInt(kv, "Network.mqtt_port", cfg.network.mqtt_port);
if (cfg.network.mqtt_port < 1 || cfg.network.mqtt_port > 65535)
@ -309,6 +350,32 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu);
cfg.features.enable_env = getBool(kv, "Features.enable_env", cfg.features.enable_env);
cfg.features.mock_env = getBool(kv, "Features.mock_env", cfg.features.mock_env);
cfg.features.mock_needcfg = getBool(kv, "Features.mock_needcfg", cfg.features.mock_needcfg);
cfg.features.strict_presence =
getBool(kv, "Features.strict_presence", cfg.features.strict_presence);
// [Hardware] imu / env are the declaration; enable_imu / enable_env are the
// older flags that said it a second time. Resolve them into one answer here,
// once, so nothing downstream has to know there were ever two keys - and so a
// rig cannot declare a sensor and then be told it is disabled.
{
auto settle = [&](const char* what, const std::string& declared, const char* flag_key,
bool& out) {
const SensorEnable e =
resolveSensorEnable(declared, out, kv.count(flag_key) > 0);
if (e.conflict)
LOG_WARN << "[Hardware] " << what << " = '" << declared << "' and [" << flag_key
<< "] disagree; the declaration wins (" << what << " "
<< (e.enabled ? "enabled" : "disabled")
<< ") -- delete the flag, it is what hardware.ini is for";
else if (e.source == SensorEnableSource::Declaration && kv.count(flag_key))
LOG_INFO << "[" << flag_key << "] is redundant: [Hardware] " << what
<< " already declares it";
out = e.enabled;
};
settle("imu", cfg.hardware.imu, "Features.enable_imu", cfg.features.enable_imu);
settle("env", cfg.hardware.env, "Features.enable_env", cfg.features.enable_env);
}
cfg.imu.device = get(kv, "IMU.device", cfg.imu.device);
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud));
@ -338,6 +405,8 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
// lands in that profile's open param map for the test modules to read.
cfg.test.default_profile = get(kv, "Test.profile", cfg.test.default_profile);
cfg.test.baseline_file = get(kv, "Test.baseline_file");
cfg.test.usbfs_min_mb = getInt(kv, "Test.usbfs_min_mb", cfg.test.usbfs_min_mb);
cfg.test.usb3_lpm_path = get(kv, "Test.usb3_lpm_path");
if (!cfg.test.baseline_file.empty())
cfg.test.baseline_file = paths::expandUser(cfg.test.baseline_file);
{
@ -378,6 +447,35 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.pitch_soft_margin_counts = getLong(kv, "Motor.pitch_soft_margin_counts", cfg.pitch_soft_margin_counts);
cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts",
cfg.enc_error_warn_counts);
cfg.homing_timeout_s = getInt(kv, "Motor.homing_timeout_s", cfg.homing_timeout_s);
// [Motor.<axis>]: this tower's drivetrain, normally from hardware.ini. Note
// the section is DOTTED - these are deliberately different keys from the flat
// [Motor] ones above, because hardware.ini is merged over config.ini and
// wins, so a shared key would silently discard the calibration that the save
// path writes back to config.ini.
auto axisDrivetrain = [&kv](const char* axis, AxisDrivetrainConfig& d) {
const std::string pre = std::string("Motor.") + axis + ".";
for (const auto& [k, v] : kv) {
(void)v;
if (k.rfind(pre, 0) == 0) { d.declared = true; break; }
}
d.r_sense_ohm = getDouble(kv, pre + "r_sense_ohm", d.r_sense_ohm);
d.run_current_ma = getDouble(kv, pre + "run_current_ma", d.run_current_ma);
// An axis that names only a run current is not asking for a reduction.
d.homing_current_ma = getDouble(kv, pre + "homing_current_ma", d.run_current_ma);
d.travel_deg = getDouble(kv, pre + "travel_deg", d.travel_deg);
d.scan_speed_deg_s = getDouble(kv, pre + "scan_speed_deg_s", d.scan_speed_deg_s);
d.homing_speed_deg_s = getDouble(kv, pre + "homing_speed_deg_s", d.homing_speed_deg_s);
d.center_speed_deg_s = getDouble(kv, pre + "center_speed_deg_s", d.center_speed_deg_s);
d.steps_per_rev = getInt(kv, pre + "steps_per_rev", static_cast<int>(d.steps_per_rev));
d.enc_deviation = getInt(kv, pre + "enc_deviation", static_cast<int>(d.enc_deviation));
d.wiring_declared = kv.count(pre + "sw_mode") || kv.count(pre + "has_encoder");
d.sw_mode = static_cast<unsigned>(getInt(kv, pre + "sw_mode", static_cast<int>(d.sw_mode)));
d.has_encoder = getBool(kv, pre + "has_encoder", d.has_encoder);
};
axisDrivetrain("yaw", cfg.drivetrain.yaw);
axisDrivetrain("pitch", cfg.drivetrain.pitch);
// [Scan]: scan-grid source (explicit CSV file, else generated).
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
@ -409,6 +507,27 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0");
if (cfg.pitch_soft_margin_counts < 0)
throw std::runtime_error("Motor.pitch_soft_margin_counts must be >= 0");
if (cfg.homing_timeout_s <= 0)
throw std::runtime_error("Motor.homing_timeout_s must be > 0");
// [Motor.<axis>]. Currents are only meaningful alongside the sense resistor
// that scales them, and a current without one would be computed against a
// guess - so demand them together rather than inventing a default resistor.
auto checkDrivetrain = [](const char* axis, const AxisDrivetrainConfig& d) {
const std::string sec = std::string("Motor.") + axis;
if (d.run_current_ma > 0.0 && d.r_sense_ohm <= 0.0)
throw std::runtime_error(sec + ".run_current_ma needs " + sec +
".r_sense_ohm (the sense resistor on that driver "
"board) to mean anything");
if (d.run_current_ma < 0.0 || d.homing_current_ma < 0.0)
throw std::runtime_error(sec + " currents must be >= 0");
if (d.travel_deg < 0.0 || d.travel_deg > 720.0)
throw std::runtime_error(sec + ".travel_deg must be within (0, 720]; the "
"direction of travel is carried by the sign of "
"counts_per_deg, not by this angle");
};
checkDrivetrain("yaw", cfg.drivetrain.yaw);
checkDrivetrain("pitch", cfg.drivetrain.pitch);
// Keys from the pre-centre-anchoring layout. Silently ignoring them would
// leave an operator convinced they had configured a heading that is in fact
@ -417,27 +536,91 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"Motor.yaw_zero_count", "Motor.pitch_zero_count", "Motor.yaw_min_deg",
"Motor.yaw_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg",
"Motor.yaw_home_zero",
// Drivetrain keys written under [Motor] instead of [Motor.<axis>]. They
// flatten to a name nothing reads, so without this they would be ignored
// in total silence - and an operator would believe they had reduced the
// homing current when they had not.
"Motor.r_sense_ohm", "Motor.run_current_ma",
"Motor.homing_current_ma", "Motor.travel_deg",
};
std::string stale;
for (const char* k : kRemovedMotorKeys)
if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6);
if (!stale.empty())
LOG_WARN << "[Motor] ignoring keys removed in the centre-anchored layout: " << stale
<< " -- travel limits are now derived from homing, and the heading "
"origin comes from yaw_heading_at_center (set it with "
"'gimbal heading setup')";
LOG_WARN << "[Motor] ignoring keys nothing reads: " << stale
<< " -- travel limits are derived from homing, the heading origin "
"comes from yaw_heading_at_center (set it with 'gimbal heading "
"setup'), and drivetrain keys belong under [Motor.yaw] / "
"[Motor.pitch], not [Motor]";
return cfg;
}
AppConfig ConfigLoader::loadFromFile(const std::string& path) {
SensorEnable resolveSensorEnable(const std::string& declared, bool flag_value, bool flag_present) {
SensorEnable r;
if (declared.empty()) {
// Nothing stated, so the legacy flag still governs. This is every tower
// that has not been migrated, and it must behave exactly as before.
r.enabled = flag_value;
r.source = flag_present ? SensorEnableSource::Flag : SensorEnableSource::Default;
return r;
}
r.enabled = (declared != "none");
r.source = SensorEnableSource::Declaration;
r.conflict = flag_present && (flag_value != r.enabled);
return r;
}
bool HardwareConfig::hasAxis(const std::string& name) const {
// An undeclared rig answers true for everything - see the header.
if (axes.empty()) return true;
return std::find(axes.begin(), axes.end(), name) != axes.end();
}
std::string HardwareConfig::axesLabel() const {
std::string s;
for (const auto& a : axes) s += (s.empty() ? "" : ", ") + a;
return s;
}
AppConfig ConfigLoader::loadFromFile(const std::string& path, const std::string& hardware_path) {
std::map<std::string, std::string> kv;
int rc = ini_parse(path.c_str(), iniHandler, &kv);
if (rc < 0) throw std::runtime_error("Cannot read config file: " + path);
if (rc > 0)
throw std::runtime_error("Parse error in config file " + path + " at line " +
std::to_string(rc));
// hardware.ini is layered ON TOP, so the assembly description wins over a
// config.ini that still carries the same keys from before the split. Any such
// leftover is named once, because it is a migration leftover to delete rather
// than a setting that is quietly doing nothing.
std::vector<std::string> shadowed;
if (!hardware_path.empty()) {
std::map<std::string, std::string> hw;
int hrc = ini_parse(hardware_path.c_str(), iniHandler, &hw);
if (hrc < 0) throw std::runtime_error("Cannot read hardware file: " + hardware_path);
if (hrc > 0)
throw std::runtime_error("Parse error in hardware file " + hardware_path +
" at line " + std::to_string(hrc));
for (const auto& [k, v] : hw) {
if (auto it = kv.find(k); it != kv.end() && it->second != v) shadowed.push_back(k);
kv[k] = v;
}
}
AppConfig cfg = fromMap(kv);
if (!hardware_path.empty()) {
cfg.hardware.declared = true;
cfg.hardware.source_path = hardware_path;
LOG_INFO << "hardware: " << hardware_path
<< (cfg.board.model.empty() ? "" : " board " + cfg.board.model)
<< (cfg.hardware.axes.empty() ? "" : " axes " + cfg.hardware.axesLabel());
for (const auto& k : shadowed)
LOG_WARN << "config.ini still sets " << k
<< ", which hardware.ini overrides -- delete it from config.ini";
}
// Anchor a relative scan grid_file to the config file's directory so it resolves
// no matter which working directory the process is launched from (the config
// itself is found via a search path that may be unrelated to the cwd).
@ -463,10 +646,108 @@ std::string trimmed(const std::string& s) {
size_t b = s.find_last_not_of(ws);
return s.substr(a, b - a + 1);
}
// Marker for a superseded value, kept so a calibration can be reverted by hand.
// It is a comment, so inih ignores it on the next read, but it is distinctive
// enough for updateIniSectionKeys to find its own earlier markers and replace
// them rather than pile them up.
const std::string kPrevMarker = ";prev ";
// The value inih would actually parse from the right-hand side of `key = value`:
// everything up to an inline comment, which is a ';' PRECEDED BY WHITESPACE (see
// ini_find_chars_or_comment in ini.c, with INI_ALLOW_INLINE_COMMENTS=1 and
// INI_INLINE_COMMENT_PREFIXES=";"). Mirroring that rule matters here: "did this
// value change?" has to compare what the program read, not the raw line, or a
// key carrying a trailing comment would look changed on every save.
// Do these two INI values denote the same NUMBER, however they are spelled?
//
// The history filter below compares what is in the file against what is about to
// be written, and the writer formats doubles with "%.10g" - so a hand-written
// "0.0" is rewritten as "0", and a plain string comparison calls that a change.
// It then records a ";prev" line whose "previous" value is the value it still
// has, stamped with the provenance of whatever save happened to be running.
//
// That is worse than cosmetic noise: ";prev" deliberately keeps exactly ONE
// generation, so a spurious entry DISPLACES the real previous value it exists to
// preserve, and attributes an untouched key to a calibration that never wrote it.
//
// Falls back to comparing text whenever either side is not a plain number, which
// is what keys like a device path need.
bool sameIniValue(const std::string& a, const std::string& b) {
if (a == b) return true;
auto asNumber = [](const std::string& s, double& out) {
if (s.empty()) return false;
char* end = nullptr;
out = std::strtod(s.c_str(), &end);
if (end == s.c_str()) return false; // nothing numeric at all
while (*end && std::isspace(static_cast<unsigned char>(*end))) ++end;
return *end == '\0'; // trailing junk: not a number
};
double x = 0.0, y = 0.0;
return asNumber(a, x) && asNumber(b, y) && x == y;
}
std::string iniValueOf(const std::string& rhs) {
bool was_space = false;
for (size_t i = 0; i < rhs.size(); ++i) {
if (was_space && rhs[i] == ';') return trimmed(rhs.substr(0, i));
was_space = std::isspace(static_cast<unsigned char>(rhs[i])) != 0;
}
return trimmed(rhs);
}
// If `t` (already trimmed) is a ";prev <key> = ..." marker, return <key>, else "".
std::string prevMarkerKey(const std::string& t) {
const size_t n = kPrevMarker.size();
if (t.compare(0, n, kPrevMarker) != 0) return "";
const size_t eq = t.find('=', n);
if (eq == std::string::npos) return "";
return trimmed(t.substr(n, eq - n));
}
} // namespace
std::string updateIniSectionKeys(const std::string& contents, const std::string& section,
const std::vector<std::pair<std::string, std::string>>& kv) {
const std::vector<std::pair<std::string, std::string>>& kv,
const std::string& provenance) {
// Which keys are actually changing, and what they hold now.
//
// Only a changing key gets a ";prev" line, and only its marker is replaced:
// a key whose value is unchanged keeps the history it has, because that still
// names the value before the current one. This also keeps the file from
// growing on every save - saveMotorGeometry rewrites all eight keys each time,
// and without this filter seven of them would be noise.
//
// Computed in a first pass because a key's ";prev" marker sits ABOVE its value
// line, so the writing pass meets the marker before it has seen whether the
// value differs.
std::vector<bool> changed(kv.size(), false);
std::vector<std::string> old_value(kv.size());
{
std::istringstream scan(contents);
std::string line, cur;
std::vector<bool> seen(kv.size(), false);
while (std::getline(scan, line)) {
const std::string t = trimmed(line);
if (t.size() >= 2 && t.front() == '[' && t.back() == ']') {
cur = trimmed(t.substr(1, t.size() - 2));
continue;
}
if (cur != section || t.empty() || t[0] == ';' || t[0] == '#') continue;
const size_t eq = line.find('=');
if (eq == std::string::npos) continue;
const std::string key = trimmed(line.substr(0, eq));
for (size_t i = 0; i < kv.size(); ++i) {
if (!seen[i] && key == kv[i].first) {
seen[i] = true;
old_value[i] = iniValueOf(line.substr(eq + 1));
changed[i] = !sameIniValue(old_value[i], kv[i].second);
break;
}
}
}
}
const bool keep_history = !provenance.empty();
std::vector<bool> written(kv.size(), false);
std::istringstream in(contents);
std::ostringstream out;
@ -475,12 +756,24 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
const std::string t = trimmed(line);
if (t.size() >= 2 && t.front() == '[' && t.back() == ']') {
cur = trimmed(t.substr(1, t.size() - 2));
} else if (cur == section && !t.empty() && t[0] != ';' && t[0] != '#') {
} else if (cur == section) {
// An earlier ";prev" for a key that is changing now is dropped here
// and re-emitted below, so exactly one generation is ever kept.
const std::string pk = prevMarkerKey(t);
if (!pk.empty()) {
bool supersede = false;
for (size_t i = 0; i < kv.size(); ++i)
if (pk == kv[i].first && changed[i]) { supersede = true; break; }
if (supersede) continue;
} else if (!t.empty() && t[0] != ';' && t[0] != '#') {
const size_t eq = line.find('=');
if (eq != std::string::npos) {
const std::string key = trimmed(line.substr(0, eq));
for (size_t i = 0; i < kv.size(); ++i) {
if (!written[i] && key == kv[i].first) {
if (keep_history && changed[i])
out << kPrevMarker << kv[i].first << " = " << old_value[i]
<< " ; " << provenance << "\n";
line = kv[i].first + " = " + kv[i].second;
written[i] = true;
break;
@ -488,6 +781,7 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
}
}
}
}
out << line << "\n";
}
// Any keys not present in the section get appended in a fresh [section] block
@ -503,7 +797,7 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
}
bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin,
long pitch_margin) {
long pitch_margin, const std::string& provenance) {
if (path.empty()) return false;
std::ifstream f(path);
if (!f) return false;
@ -529,7 +823,7 @@ bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_ma
{"pitch_angle_at_center", num(geo.pitch.deg_at_center)},
{"pitch_soft_margin_counts", std::to_string(pitch_margin)},
};
const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv);
const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv, provenance);
// Write a sibling temp file and rename over the original: a truncating write
// that dies partway leaves config.ini destroyed, and three commands rewrite

View File

@ -16,6 +16,10 @@ const char* stateName(int s) {
case 2: return "HOMING";
case 3: return "READY";
case 4: return "ERROR";
// Appended in the firmware enum rather than placed in lifecycle order,
// because these numbers are on the wire: inserting it would renumber
// every later state and a READY axis would read back as ERROR.
case 5: return "NEEDCFG";
default: return "?";
}
}
@ -115,6 +119,7 @@ DumpData parseDump(const std::string& block) {
d.uptime_ms = asInt(get(hdr, "uptime", "0"));
d.mcusr = static_cast<unsigned>(asInt(get(hdr, "mcusr", "0")));
d.free_ram = asInt(get(hdr, "free_ram", "0"));
d.reset_text = get(hdr, "reset");
d.reset_flags = bitsSet(d.mcusr, kMcusr);
// Per-axis lines: "DUMP <Y|P> state=.." and "DUMP <Y|P> TMC ..".
@ -162,6 +167,15 @@ DumpData parseDump(const std::string& block) {
ax.center_count = asInt(get(m, "center", "0"));
ax.hold_target = asInt(get(m, "hold_target", "0"));
ax.speed = asInt(get(m, "speed", "0"));
ax.global_scaler = asInt(get(m, "gs", "-1"));
ax.run_irun = asInt(get(m, "irun", "-1"));
ax.homing_irun = asInt(get(m, "irun_home", "-1"));
ax.steps_per_rev = asInt(get(m, "steps_per_rev", "-1"));
ax.enc_deviation = asInt(get(m, "enc_dev", "-1"));
ax.homing_speed = asInt(get(m, "homing_speed", "-1"));
ax.center_speed = asInt(get(m, "center_speed", "-1"));
ax.sw_mode = asInt(get(m, "sw_mode", "-1"));
ax.cfg_received = asInt(get(m, "cfg_rx", "-1"), -1);
ax.hold_corrections = asInt(get(m, "hold_corr", "0"));
ax.hold_peak_dev = asInt(get(m, "hold_peak", "0"));
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));
@ -186,8 +200,17 @@ std::vector<std::string> formatDump(const DumpData& d) {
}
{
std::ostringstream os;
os << "reset cause (mcusr=0x" << std::hex << d.mcusr << std::dec << "): "
<< join(d.reset_flags);
os << "reset cause (0x" << std::hex << d.mcusr << std::dec << "): ";
if (!d.reset_text.empty()) {
// The firmware named it, so no guessing is involved.
os << d.reset_text;
} else {
// Firmware predating the `reset=` field. The only decode available is
// the AVR one, and on a non-AVR board it is wrong - so say that it is
// an assumption rather than stating it as fact.
os << join(d.reset_flags) << " [assumed AVR MCUSR bits; firmware does "
"not name it]";
}
out.push_back(os.str());
}
for (const auto& ax : d.axes) {

View File

@ -0,0 +1,92 @@
#include "fgc/FirmwareConfig.h"
#include <sstream>
namespace fgc {
namespace {
// One answerable key: where its value comes from, and whether the host has it.
struct Answer {
bool have = false;
long long value = 0;
};
Answer answerFor(const std::string& key, const AxisConfigSource& s) {
// Motor current. All three come from the same conversion, so they are
// available together or not at all.
if (key == "global_scaler") return {s.have_current, s.global_scaler};
if (key == "run_irun") return {s.have_current, s.run_irun};
if (key == "homing_irun") return {s.have_current, s.homing_irun};
// Position anchors. `center` / `margin` are the names the existing CENTER
// and MARGIN commands use.
if (key == "center_count" || key == "center") return {true, s.center_count};
if (key == "soft_margin" || key == "margin") return {true, s.soft_margin};
if (key == "steps_per_rev") return {s.have_travel, s.steps_per_rev};
if (key == "enc_deviation") return {s.have_deviation, s.enc_deviation};
// A VMAX of 0 is not a slow axis, it is a stopped one, so an unconverted
// speed is reported as missing rather than sent.
if (key == "normal_speed") return {s.normal_vmax > 0, s.normal_vmax};
if (key == "homing_speed") return {s.homing_vmax > 0, s.homing_vmax};
if (key == "center_speed") return {s.center_vmax > 0, s.center_vmax};
if (key == "sw_mode") return {s.have_wiring, static_cast<long long>(s.sw_mode)};
if (key == "has_encoder") return {s.have_wiring, s.has_encoder ? 1 : 0};
return {}; // not in the vocabulary
}
bool known(const std::string& key) {
static const char* kKeys[] = {
"global_scaler", "run_irun", "homing_irun", "center_count", "center",
"soft_margin", "margin", "steps_per_rev", "enc_deviation",
"normal_speed", "homing_speed", "center_speed", "sw_mode", "has_encoder",
};
for (const char* k : kKeys)
if (key == k) return true;
return false;
}
} // namespace
std::optional<NeedRequest> parseNeedLine(const std::string& line) {
std::istringstream is(line);
std::string tok;
if (!(is >> tok) || tok != "NEED") return std::nullopt;
std::string axis;
if (!(is >> axis)) return std::nullopt;
if (axis.size() != 1) return std::nullopt;
const char a = static_cast<char>(std::toupper(static_cast<unsigned char>(axis[0])));
if (a != 'Y' && a != 'P') return std::nullopt;
NeedRequest r;
r.axis = a;
while (is >> tok) r.keys.push_back(tok);
return r;
}
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src) {
FirmwareConfigPlan plan;
const std::string axis(1, need.axis);
for (const auto& key : need.keys) {
if (!known(key)) {
// The firmware wants something this host has never heard of, which
// means it is newer. Naming it is the whole diagnostic.
plan.unknown.push_back(key);
continue;
}
const Answer a = answerFor(key, src);
if (!a.have) {
plan.unanswered.push_back(key);
continue;
}
plan.sets.push_back("SET " + axis + " " + key + " " + std::to_string(a.value));
}
return plan;
}
} // namespace fgc

211
src/core/HardwareChecks.cpp Normal file
View File

@ -0,0 +1,211 @@
#include "fgc/HardwareChecks.h"
#include <algorithm>
#include <cctype>
#include <string>
namespace fgc {
namespace {
std::string trimmed(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return "";
const auto e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
// A sysfs integer, or nullopt when the node was empty/unreadable/not a number.
bool asInt(const std::string& s, long& out) {
const std::string t = trimmed(s);
if (t.empty()) return false;
if (!std::all_of(t.begin(), t.end(), [](unsigned char c) { return std::isdigit(c); }))
return false;
try {
out = std::stol(t);
} catch (...) {
return false;
}
return true;
}
} // namespace
CheckResult checkUsbfsMemory(const std::string& sysfs_value, int min_mb) {
CheckResult r;
long mb = 0;
if (!asInt(sysfs_value, mb)) {
r.pass = false;
r.detail = "could not read usbfs_memory_mb -- expected a number at "
"/sys/module/usbcore/parameters/usbfs_memory_mb";
return r;
}
r.pass = mb >= min_mb;
if (r.pass) {
r.detail = "usbfs_memory_mb " + std::to_string(mb);
} else {
r.detail = "usbfs_memory_mb is " + std::to_string(mb) + ", needs >= " +
std::to_string(min_mb) +
" -- frames will stall under load. It resets on every reboot unless "
"usbcore.usbfs_memory_mb=" + std::to_string(min_mb) +
" is on the kernel cmdline (known-issues #19)";
}
return r;
}
CheckResult checkUsb3Lpm(const std::string& permit_value) {
CheckResult r;
const std::string v = trimmed(permit_value);
if (v.empty()) {
r.pass = false;
r.detail = "could not read usb3_lpm_permit for the camera's port";
return r;
}
r.pass = (v == "0");
r.detail = r.pass ? "USB3 LPM off on the camera port"
: "USB3 LPM is permitted (usb3_lpm_permit=" + v +
") -- write 0 and re-enumerate, and persist it with a udev rule; "
"the per-device usb3_hardware_lpm_u* nodes are read-only "
"(known-issues #19)";
return r;
}
CheckResult checkI2cFunctionality(unsigned long funcs) {
CheckResult r;
r.pass = (funcs & kI2cFuncI2c) != 0;
r.detail = r.pass ? "adapter supports real I2C transactions"
: "adapter is SMBus-only (I2C_FUNCS lacks I2C_FUNC_I2C) -- the SHT41 will "
"bind and then fail every read. Point [Env] i2c_device at a real I2C "
"adapter; 'i2cdetect -F <n>' is the manual check (known-issues #2)";
return r;
}
CheckResult checkDrivetrainAdopted(const std::string& axis, const DrivetrainExpectation& want,
const DrivetrainEcho& got) {
CheckResult r;
// Nothing declared for this axis means nothing was sent, and the firmware's
// compile-time defaults are the intended configuration. There is no skew to
// find.
if (!want.declared) {
r.ran = false;
r.detail = "no [Motor." + axis + "] current declared; firmware defaults are in force";
return r;
}
if (got.absent()) {
r.pass = false;
r.detail = axis +
": firmware did not echo gs/irun/irun_home -- it predates the CURRENT command, "
"so whatever hardware.ini asks for is NOT being applied. Re-flash it";
return r;
}
// A field is compared only when it was actually echoed. Comparing against -1
// would report "firmware has -1", which reads as a wrong value rather than
// as a field this firmware does not report - and sends someone to re-send a
// CURRENT that was never the problem.
int missing = 0;
auto compare = [&](const char* field, int wanted, long saw) {
if (saw < 0) { ++missing; return; }
if (saw == wanted) return;
r.pass = false;
if (!r.detail.empty()) r.detail += "; ";
r.detail += std::string(field) + " wanted " + std::to_string(wanted) + ", firmware has " +
std::to_string(saw);
};
compare("GLOBALSCALER", want.global_scaler, got.global_scaler);
compare("IRUN", want.run_irun, got.run_irun);
compare("homing IRUN", want.homing_irun, got.homing_irun);
if (missing > 0 && r.pass) {
r.pass = false;
r.detail = axis + ": firmware echoes only part of the current (" +
std::to_string(missing) +
" of 3 fields absent), so it cannot be confirmed -- it predates the full "
"CURRENT echo. Re-flash it";
return r;
}
if (r.pass) {
r.detail = axis + ": firmware has the declared current (GLOBALSCALER " +
std::to_string(want.global_scaler) + ", IRUN " + std::to_string(want.run_irun) +
", homing " + std::to_string(want.homing_irun) + ")";
} else {
r.detail = axis + ": " + r.detail +
" -- the CURRENT command did not land. A homing current that is not in force is "
"the case that damages the endstop";
}
return r;
}
CheckResult checkDeclaredValues(const std::string& axis, const DeclaredValue* values, int count) {
CheckResult r;
int checked = 0, absent = 0;
for (int i = 0; i < count; ++i) {
const DeclaredValue& v = values[i];
if (!v.want) continue; // nothing declared: nothing to be wrong about
if (v.got < 0) {
// The firmware does not echo this field at all, which is a version
// skew rather than a mismatch - and it means the value cannot be
// confirmed either way, so it must not read as a pass.
++absent;
continue;
}
++checked;
if (v.got != v.value) {
r.pass = false;
if (!r.detail.empty()) r.detail += "; ";
r.detail += std::string(v.name) + " wanted " + std::to_string(v.value) +
", firmware has " + std::to_string(v.got);
}
}
if (checked == 0 && absent == 0) {
r.ran = false;
r.detail = axis + ": nothing declared for this axis beyond the current";
return r;
}
if (absent > 0 && r.pass) {
r.pass = false;
r.detail = axis + ": the firmware does not echo " + std::to_string(absent) +
" declared value(s), so what it is running on cannot be confirmed -- it "
"predates the config handshake. Re-flash it";
return r;
}
if (!r.pass) {
r.detail = axis + ": " + r.detail +
" -- the firmware is not running what hardware.ini declares. A SET was lost, "
"or the axis never left NEEDCFG";
return r;
}
r.detail = axis + ": firmware holds all " + std::to_string(checked) + " declared values";
return r;
}
CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel) {
CheckResult r;
if (half_travel <= 0) {
r.ran = false;
r.detail = axis + ": not homed, so the travel range is not known yet";
return r;
}
const long lo = center - half_travel;
if (lo >= 0) {
r.detail = axis + ": travel is entirely positive (" + std::to_string(lo) + " .. " +
std::to_string(center + half_travel) + ")";
return r;
}
r.pass = false;
r.detail = axis + ": homed travel reaches " + std::to_string(lo) +
" -- below zero. [Motor] " + axis + "_center_count is " +
std::to_string(center) + " but half the travel is " +
std::to_string(half_travel) + ", so raise the centre above that (a round "
"number with headroom, e.g. " +
std::to_string(((half_travel / 1000000) + 1) * 1000000) +
"). The centre is arbitrary; only the span is physical";
return r;
}
} // namespace fgc

View File

@ -0,0 +1,171 @@
#include "fgc/HardwarePresence.h"
#include <algorithm>
#include <string>
namespace fgc {
namespace {
bool listed(const std::vector<std::string>& v, const std::string& name) {
return std::find(v.begin(), v.end(), name) != v.end();
}
// A sensor slot is declared when it names a model. Empty means the file said
// nothing; "none" means it said "deliberately not fitted" - and the difference
// matters, because only the second one makes a found sensor a stale-file warning.
bool names(const std::string& slot) { return !slot.empty() && slot != "none"; }
bool stated(const std::string& slot) { return !slot.empty(); }
void missing(PresenceReport& r, std::string subsystem, std::string detail) {
r.findings.push_back({PresenceIssue::MissingDeclared, std::move(subsystem), std::move(detail)});
}
void undeclared(PresenceReport& r, std::string subsystem, std::string detail) {
r.findings.push_back({PresenceIssue::UndeclaredFound, std::move(subsystem), std::move(detail)});
}
} // namespace
bool PresenceReport::anyMissing() const {
return std::any_of(findings.begin(), findings.end(),
[](const PresenceFinding& f) { return f.issue == PresenceIssue::MissingDeclared; });
}
bool PresenceReport::anyUndeclared() const {
return std::any_of(findings.begin(), findings.end(),
[](const PresenceFinding& f) { return f.issue == PresenceIssue::UndeclaredFound; });
}
std::vector<std::string> PresenceReport::missing() const {
std::vector<std::string> out;
for (const auto& f : findings)
if (f.issue == PresenceIssue::MissingDeclared) out.push_back(f.subsystem);
return out;
}
PresenceReport reconcilePresence(const HardwareConfig& declared, const FoundHardware& found) {
PresenceReport r;
// Nothing was read, so there is nothing to contradict. Every un-migrated
// tower lands here and must behave exactly as it did before.
if (!declared.declared) return r;
r.reconciled = true;
// ---- axes ----
// An empty axes list states nothing, matching hasAxis()'s "answers true for
// everything". Reconciling it would turn silence into a fault.
if (!declared.axes.empty()) {
if (!found.axes_known) {
missing(r, "motor telemetry",
"no telemetry arrived, so the declared axes (" + declared.axesLabel() +
") could not be confirmed -- check [Serial] device and that the MCU is "
"flashed and enumerating");
} else {
for (const auto& a : declared.axes)
if (!listed(found.axes, a))
missing(r, "axis " + a,
"declared in " + declared.source_path +
" but absent from telemetry -- the driver is not answering, or "
"the firmware was built for fewer axes than this rig has");
for (const auto& a : found.axes)
if (!listed(declared.axes, a))
undeclared(r, "axis " + a,
"present in telemetry but not in [Hardware] axes -- add it, or the "
"declaration is describing a different rig");
}
}
// ---- IMU ----
if (names(declared.imu) && !found.imu)
missing(r, "imu",
"declared as '" + declared.imu +
"' but no IMU came up -- check [IMU] device; declaring it here is what "
"enables it");
if (stated(declared.imu) && !names(declared.imu) && found.imu)
undeclared(r, "imu", "running, but [Hardware] imu says none -- the declaration is stale");
// ---- env sensor ----
if (names(declared.env) && !found.env)
missing(r, "env",
"declared as '" + declared.env +
"' but no ambient sensor came up -- check [Env] i2c_device, and that it is a "
"real I2C adapter rather than SMBus-only");
if (stated(declared.env) && !names(declared.env) && found.env)
undeclared(r, "env", "running, but [Hardware] env says none -- the declaration is stale");
// ---- cameras ----
if (!declared.cameras.empty()) {
for (const auto& c : declared.cameras)
if (!listed(found.cameras, c))
missing(r, "camera " + c,
"declared in " + declared.source_path +
" but not found -- check the [Camera." + c +
"] block is enabled and the device is on the bus");
for (const auto& c : found.cameras)
if (!listed(declared.cameras, c))
undeclared(r, "camera " + c,
"active but not in [Hardware] cameras -- add it, or the declaration is "
"describing a different rig");
// Configured and declared is not the same as present on the bus. The
// source counts what it opened without saying which, so the shortfall is
// reported as a number and `test hardware camera` names the device.
const int want = static_cast<int>(found.cameras.size());
if (found.cameras_opened >= 0 && found.cameras_opened < want)
missing(r, "cameras",
std::to_string(want) + " configured, only " +
std::to_string(found.cameras_opened) +
" opened -- one is not on the bus; 'test hardware camera' names which");
}
return r;
}
PresenceVerdict presenceVerdict(const PresenceReport& report, bool strict, bool degraded) {
PresenceVerdict v;
if (report.findings.empty()) return v; // Proceed, nothing to say
if (!report.anyMissing()) {
// Only stale-file warnings. Never fatal: the rig has MORE than was
// declared, which is a documentation fault, not a broken tower.
v.action = PresenceAction::WarnOnly;
v.reason = "hardware.ini is out of date with this rig";
return v;
}
std::string gone;
for (const auto& m : report.missing()) gone += (gone.empty() ? "" : ", ") + m;
if (!strict) {
v.action = PresenceAction::WarnOnly;
v.reason = "declared hardware is missing (" + gone +
"); continuing because [Features] strict_presence is off -- set it once this "
"tower's hardware.ini is known good";
return v;
}
if (degraded) {
// Loudly, per the plan: this is field triage, and it must not become the
// way a tower is normally run.
v.action = PresenceAction::WarnOnly;
v.reason = "RUNNING DEGRADED BY REQUEST: declared hardware is missing (" + gone +
") and --degraded overrode the refusal -- this tower is not doing the job it "
"is declared to do";
return v;
}
v.action = PresenceAction::Refuse;
v.reason = "declared hardware is missing: " + gone +
" -- fix the rig, correct " +
"hardware.ini, or pass --degraded to start anyway";
return v;
}
std::vector<std::string> formatPresence(const PresenceReport& report) {
std::vector<std::string> out;
for (const auto& f : report.findings)
if (f.issue == PresenceIssue::MissingDeclared) out.push_back(f.subsystem + ": " + f.detail);
for (const auto& f : report.findings)
if (f.issue == PresenceIssue::UndeclaredFound) out.push_back(f.subsystem + ": " + f.detail);
return out;
}
} // namespace fgc

View File

@ -74,6 +74,12 @@ const std::vector<HelpSection>& helpCatalog() {
"the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}},
{"gimbal current <y|p> <scaler> <irun> <irun_home>",
"Set motor current, as raw TMC register fields.",
{"Normally sent automatically from hardware.ini [Motor.<axis>], which states",
"the current in milliamps and lets the host convert it using this board's",
"sense resistor. Setting it by hand does not stay secret: the next home",
"checks the firmware's reported current against the configured one."}},
{"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>",
"Other firmware motor controls.", {}},
}},
@ -114,6 +120,13 @@ const std::vector<HelpSection>& helpCatalog() {
"token names a config profile. Each run is compared to a pinned baseline",
"and the previous run. Esc cancels.",
"Example: test gimbal homing / test list / test baseline"}},
{"test hardware [axes|motor|imu|env|camera]",
"Check the rig against what hardware.ini declares.", {
"Preconditions, not measurements: the declared axes appear in telemetry and",
"no others; the firmware echoes the drivetrain current hardware.ini asked for;",
"a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not",
"SMBus-only) and its CRC checks out; the cameras opened and the USB",
"preconditions hold. Nothing declared reports 'not checked', never PASS."}},
{"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",

View File

@ -92,6 +92,34 @@ std::optional<std::string> resolveConfigPath(const std::string& cliArg) {
return std::nullopt;
}
std::vector<std::string> hardwareSearchPaths(const std::string& cliArg,
const std::string& configPath) {
std::vector<std::string> paths;
if (!cliArg.empty()) paths.push_back(expandUser(cliArg));
if (const char* env = std::getenv("FGC_HARDWARE"); env && *env)
paths.push_back(expandUser(env));
paths.push_back("/etc/fire_gimbal_control/hardware.ini");
// Beside config.ini, so a checkout with a local config.ini picks up a local
// hardware.ini without needing root or an env var.
if (!configPath.empty()) {
const fs::path dir = fs::path(expandUser(configPath)).parent_path();
paths.push_back((dir.empty() ? fs::path(".") : dir) / "hardware.ini");
}
return paths;
}
std::optional<std::string> resolveHardwarePath(const std::string& cliArg,
const std::string& configPath) {
std::error_code ec;
for (const auto& p : hardwareSearchPaths(cliArg, configPath)) {
if (fs::is_regular_file(p, ec)) return p;
}
return std::nullopt;
}
std::string defaultOutputDir() {
std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share"));
return (fs::path(base) / "fire_gimbal_control" / "images").string();

View File

@ -144,7 +144,7 @@ void PitchSetupRoutine::run() {
// 1. Preconditions.
if (!imu_.connected()) {
LOG_WARN << "pitch setup aborted: IMU not connected (set [Features] enable_imu)";
LOG_WARN << "pitch setup aborted: IMU not connected (declare [Hardware] imu)";
return;
}

View File

@ -29,6 +29,7 @@ AxisState stateFromChar(char c) {
case 'H': case 'h': return AxisState::Homing;
case 'A': case 'a': return AxisState::Ready;
case 'E': case 'e': return AxisState::Error;
case 'C': case 'c': return AxisState::NeedConfig;
default: return AxisState::Unknown;
}
}

View File

@ -3,6 +3,13 @@
#include "fgc/Calibration.h" // unwrapNear
#include "fgc/DiagParser.h"
#include "fgc/AxisSpeed.h"
#include "fgc/HardwareChecks.h"
#include "fgc/HardwarePresence.h"
#include "fgc/ICameraSource.h"
#include "fgc/IEnvSensor.h"
#include "fgc/TmcCurrent.h"
#include "fgc/sensors/I2cBus.h"
#include "fgc/DumpParser.h"
#include "fgc/HostMetrics.h"
#include "fgc/IImuSource.h"
@ -10,6 +17,8 @@
#include "fgc/Logger.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <chrono>
#include <cmath>
#include <cstdlib>
@ -109,6 +118,15 @@ bool resolveTestSelection(const std::string& subsystem, const std::string& test,
else if (test == "health") m = T_IMU_HEALTH;
else if (test == "drift") m = T_IMU_DRIFT;
else { err = "unknown imu test: " + test; return false; }
} else if (subsystem == "hardware") {
sub_ok = true;
if (test.empty()) m = T_HW_ALL;
else if (test == "axes") m = T_HW_AXES;
else if (test == "motor") m = T_HW_MOTOR;
else if (test == "imu") m = T_HW_IMU;
else if (test == "env") m = T_HW_ENV;
else if (test == "camera") m = T_HW_CAMERA;
else { err = "unknown hardware test: " + test; return false; }
} else if (subsystem == "host") {
sub_ok = true;
if (test.empty()) m = T_HOST_ALL;
@ -125,9 +143,10 @@ bool resolveTestSelection(const std::string& subsystem, const std::string& test,
}
TestRunner::TestRunner(IMotorController& motor, IImuSource* imu, Geometry geo,
uint32_t selection, TestProfile profile, std::string disk_path)
uint32_t selection, TestProfile profile, std::string disk_path,
HardwareTestContext hw)
: motor_(motor), imu_(imu), geo_(geo), selection_(selection),
profile_(std::move(profile)), disk_path_(std::move(disk_path)) {}
profile_(std::move(profile)), disk_path_(std::move(disk_path)), hw_(std::move(hw)) {}
TestRunner::~TestRunner() {
cancel_ = true;
@ -199,6 +218,11 @@ void TestRunner::run() {
if (!cancelled() && (selection_ & T_IMU_HEALTH)) testImuHealth(r);
if (!cancelled() && (selection_ & T_IMU_DRIFT)) testImuDrift(r);
if (!cancelled() && (selection_ & T_HOST_ALL)) testHost(r);
if (!cancelled() && (selection_ & T_HW_AXES)) testHwAxes(r);
if (!cancelled() && (selection_ & T_HW_MOTOR)) testHwMotor(r);
if (!cancelled() && (selection_ & T_HW_IMU)) testHwImu(r);
if (!cancelled() && (selection_ & T_HW_ENV)) testHwEnv(r);
if (!cancelled() && (selection_ & T_HW_CAMERA)) testHwCamera(r);
r.all_pass = true;
for (const auto& s : r.sections)
@ -770,4 +794,323 @@ void TestRunner::testHost(TestReport& r) {
}
}
// ---------------------------------------------------------------------------
// `test hardware` - is this rig the one hardware.ini says it is?
//
// These differ from every other leaf: they measure nothing and trend nothing.
// Each is a precondition that either holds or does not, so each metric is a
// 1/0 with a threshold of 1, and the detail lives in the section notes where an
// operator reads it.
namespace {
// A pass/fail metric. Uses the same threshold machinery as the measured leaves
// so rollup(), formatting and comparison all keep working unchanged.
void verdict(TestSection& s, const std::string& name, const CheckResult& c) {
if (!c.ran) {
s.notes.push_back(name + ": not checked -- " + c.detail);
return;
}
auto& m = add(s, name, c.pass ? 1 : 0, "", /*lower_is_better=*/false);
thresh(m, 1);
s.notes.push_back(name + ": " + c.detail);
}
// Pull one axis's echo out of a DUMP, or -1 when the axis or field is absent.
long axisField(const DumpData& d, char code, long DumpAxis::*field) {
for (const auto& ax : d.axes)
if (ax.axis == code) return ax.*field;
return -1;
}
long echoSteps(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::steps_per_rev); }
long echoDev(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::enc_deviation); }
long echoSwMode(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::sw_mode); }
long echoHomingSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::homing_speed); }
long echoCenterSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::center_speed); }
long echoSpeed(const DumpData& d, char c) { return axisField(d, c, &DumpAxis::speed); }
// The VMAX a declared deg/s becomes on this axis, or 0 when it cannot be formed.
// Mirrors Application's fallback: the live calibration when it is trustworthy,
// otherwise the one the declaration implies.
long speedVmax(double deg_s, const AxisDrivetrainConfig& d, const AxisMap& map) {
if (deg_s <= 0.0) return 0;
const double live = std::fabs(map.counts_per_deg);
const double cpd = live >= kMinPlausibleCountsPerDeg ? map.counts_per_deg
: d.preHomeCountsPerDeg();
const auto p = planAxisSpeed(deg_s, cpd);
return p.ok ? p.vmax : 0;
}
std::string readSysfs(const std::string& path) {
if (path.empty()) return "";
std::ifstream f(path);
if (!f) return "";
std::string line;
std::getline(f, line);
return line;
}
} // namespace
void TestRunner::testHwAxes(TestReport& r) {
TestSection s; s.id = "hw_axes"; s.title = "declared axes vs telemetry";
setProgress(s.id, 1, 1, "reading telemetry");
if (!hw_.declared || !hw_.declared->declared) {
s.notes.push_back("no hardware.ini in force; nothing is declared to check against");
s.ran = false;
r.sections.push_back(std::move(s));
return;
}
// One ST line is enough: yaw is implied by any well-formed line, pitch by
// its own segment.
MotorTelemetry t = motor_.telemetry();
FoundHardware f;
// NEEDCFG counts as telemetry arriving: the link is fine and the axis is
// talking, it just has not been told what it is driving. Treating it as "no
// telemetry" would blame the serial port for a configuration gap.
f.axes_known = t.yaw.state != AxisState::Unknown;
if (f.axes_known) {
f.axes.push_back("yaw");
if (t.pitch_present) f.axes.push_back("pitch");
}
// Only the axis findings belong here; the sensors and cameras have their own
// leaves, and a declaration they disagree with would otherwise fail twice.
HardwareConfig axes_only;
axes_only.declared = true;
axes_only.source_path = hw_.declared->source_path;
axes_only.axes = hw_.declared->axes;
const PresenceReport pr = reconcilePresence(axes_only, f);
CheckResult c;
c.pass = !pr.anyMissing() && !pr.anyUndeclared();
if (c.pass) {
c.detail = "telemetry carries exactly the declared axes (" + axes_only.axesLabel() + ")";
} else {
for (const auto& line : formatPresence(pr))
c.detail += (c.detail.empty() ? "" : "; ") + line;
}
verdict(s, "axes_match", c);
rollup(s);
r.sections.push_back(std::move(s));
}
void TestRunner::testHwMotor(TestReport& r) {
TestSection s; s.id = "hw_motor"; s.title = "firmware adopted the declared drivetrain";
setProgress(s.id, 1, 1, "reading DUMP");
if (!hw_.drive) {
s.notes.push_back("no drivetrain declared; firmware defaults are in force");
s.ran = false;
r.sections.push_back(std::move(s));
return;
}
motor_.sendCommand("DUMP");
std::this_thread::sleep_for(250ms);
const DumpData d = parseDump(motor_.lastDump());
if (r.fw.empty() && !d.build.empty()) r.fw = d.build;
if (!d.valid) {
CheckResult c;
c.pass = false;
c.detail = "no parseable DUMP came back, so what the firmware is running could not be "
"confirmed";
verdict(s, "dump_readable", c);
rollup(s);
r.sections.push_back(std::move(s));
return;
}
auto check_axis = [&](const char* name, char code, const AxisDrivetrainConfig& cfg) {
DrivetrainExpectation want;
if (cfg.run_current_ma > 0.0 && cfg.r_sense_ohm > 0.0) {
const auto plan = planTmcCurrent(cfg.r_sense_ohm, cfg.run_current_ma,
cfg.homing_current_ma);
if (plan.ok) {
want.declared = true;
want.global_scaler = plan.global_scaler;
want.run_irun = plan.run_irun;
want.homing_irun = plan.homing_irun;
}
}
DrivetrainEcho got;
for (const auto& ax : d.axes)
if (ax.axis == code) {
got.global_scaler = ax.global_scaler;
got.run_irun = ax.run_irun;
got.homing_irun = ax.homing_irun;
}
verdict(s, std::string(name) + "_current", checkDrivetrainAdopted(name, want, got));
// Everything else the host declares and the firmware is told. Before the
// config handshake these lived only in the firmware's own table, where a
// stale or defaulted value was invisible from here.
const AxisMap& map = (code == 'Y') ? hw_.geo_yaw : hw_.geo_pitch;
const long want_normal = speedVmax(cfg.scan_speed_deg_s, cfg, map);
const DeclaredValue vals[] = {
{"steps_per_rev", cfg.steps_per_rev > 0, cfg.steps_per_rev, echoSteps(d, code)},
{"enc_deviation", cfg.enc_deviation > 0, cfg.enc_deviation, echoDev(d, code)},
{"sw_mode", cfg.wiring_declared, (long)cfg.sw_mode, echoSwMode(d, code)},
{"homing_speed", cfg.homing_speed_deg_s > 0,
speedVmax(cfg.homing_speed_deg_s, cfg, map), echoHomingSpeed(d, code)},
{"center_speed", cfg.center_speed_deg_s > 0,
speedVmax(cfg.center_speed_deg_s, cfg, map), echoCenterSpeed(d, code)},
{"normal_speed", want_normal > 0, want_normal, echoSpeed(d, code)},
};
verdict(s, std::string(name) + "_declared",
checkDeclaredValues(name, vals, (int)(sizeof(vals) / sizeof(vals[0]))));
};
check_axis("yaw", 'Y', hw_.drive->yaw);
if (!hw_.declared || hw_.declared->hasAxis("pitch")) check_axis("pitch", 'P', hw_.drive->pitch);
rollup(s);
r.sections.push_back(std::move(s));
}
void TestRunner::testHwImu(TestReport& r) {
TestSection s; s.id = "hw_imu"; s.title = "declared IMU is present and streaming";
setProgress(s.id, 1, 1, "sampling");
const bool declared =
hw_.declared && hw_.declared->declared && !hw_.declared->imu.empty() &&
hw_.declared->imu != "none";
if (!declared) {
s.notes.push_back("no IMU declared");
s.ran = false;
r.sections.push_back(std::move(s));
return;
}
CheckResult c;
if (!imu_) {
c.pass = false;
c.detail = "declared as '" + hw_.declared->imu +
"' but no IMU source was built -- check [IMU] device; the declaration itself is "
"what enables it";
} else if (!imu_->connected()) {
c.pass = false;
c.detail = "source exists but is not connected -- the port opened without a 0xFA-framed "
"stream arriving; check the baud and that it is the MTi's node";
} else if (!imu_->sample()) {
c.pass = false;
c.detail = "connected but no sample within the staleness window -- the stream stalled";
} else {
c.detail = "'" + hw_.declared->imu + "' streaming";
}
verdict(s, "imu_streaming", c);
rollup(s);
r.sections.push_back(std::move(s));
}
void TestRunner::testHwEnv(TestReport& r) {
TestSection s; s.id = "hw_env"; s.title = "declared ambient sensor, on a real I2C bus";
setProgress(s.id, 1, 2, "probing bus");
const bool declared =
hw_.declared && hw_.declared->declared && !hw_.declared->env.empty() &&
hw_.declared->env != "none";
if (!declared) {
s.notes.push_back("no ambient sensor declared");
s.ran = false;
r.sections.push_back(std::move(s));
return;
}
// The adapter class first: an SMBus-only bus makes every later symptom look
// like a dead sensor, so naming it first is what saves the debugging time.
if (hw_.i2c_device.empty()) {
CheckResult c;
c.pass = false;
c.detail = "declared as '" + hw_.declared->env + "' but [Env] i2c_device is empty";
verdict(s, "i2c_adapter", c);
} else {
I2cBus bus(hw_.i2c_device, 0x44);
unsigned long funcs = 0;
CheckResult c;
if (!bus.open()) {
c.pass = false;
c.detail = "cannot open " + hw_.i2c_device +
" -- wrong bus number, or the user is not in the i2c group";
} else if (!bus.functionality(funcs)) {
c.pass = false;
c.detail = "I2C_FUNCS ioctl failed on " + hw_.i2c_device;
} else {
c = checkI2cFunctionality(funcs);
c.detail = hw_.i2c_device + ": " + c.detail;
}
verdict(s, "i2c_adapter", c);
}
setProgress(s.id, 2, 2, "reading sensor");
CheckResult m;
if (!hw_.env) {
m.pass = false;
m.detail = "declared as '" + hw_.declared->env +
"' but no sensor was built -- check [Env] i2c_device; the declaration itself is "
"what enables it";
} else if (auto sample = hw_.env->sample()) {
m.detail = "CRC-valid reading from " + hw_.env->name();
} else {
m.pass = false;
m.detail = "no valid reading within the staleness window -- every transfer is failing, or "
"the CRC never checks out";
}
verdict(s, "env_reading", m);
rollup(s);
r.sections.push_back(std::move(s));
}
void TestRunner::testHwCamera(TestReport& r) {
TestSection s; s.id = "hw_camera"; s.title = "cameras present, and the USB preconditions";
setProgress(s.id, 1, 2, "counting cameras");
// The USB preconditions are preconditions FOR A CAMERA. With none configured
// and no source built, there is nothing they could be protecting, and
// checking them would fail a dev box for a fault it cannot have.
if (hw_.configured_cameras.empty() && !hw_.camera) {
s.notes.push_back("no cameras configured; the USB preconditions have nothing to protect");
s.ran = false;
r.sections.push_back(std::move(s));
return;
}
if (!hw_.configured_cameras.empty()) {
CheckResult c;
const int want = static_cast<int>(hw_.configured_cameras.size());
const int got = hw_.camera ? hw_.camera->cameraCount() : -1;
if (got < 0) {
c.ran = false;
c.detail = "no camera source in this run";
} else if (got < want) {
c.pass = false;
c.detail = std::to_string(want) + " configured, only " + std::to_string(got) +
" opened -- one is not on the bus or is held by another process";
} else {
c.detail = std::to_string(got) + " of " + std::to_string(want) + " opened";
}
verdict(s, "cameras_found", c);
}
setProgress(s.id, 2, 2, "USB preconditions");
verdict(s, "usbfs_memory", checkUsbfsMemory(readSysfs(hw_.usbfs_path), hw_.usbfs_min_mb));
// LPM lives on a port node that differs per machine, so it is checked only
// when the path is known. Silence here means "not located", never "fine".
if (!hw_.lpm_path.empty()) {
verdict(s, "usb3_lpm", checkUsb3Lpm(readSysfs(hw_.lpm_path)));
} else {
s.notes.push_back(
"usb3_lpm: not checked -- no port node configured. Known-issues #19 has how to find "
"and persist it; it re-enables on every reboot without a udev rule");
}
rollup(s);
r.sections.push_back(std::move(s));
}
} // namespace fgc

118
src/core/TmcCurrent.cpp Normal file
View File

@ -0,0 +1,118 @@
#include "fgc/TmcCurrent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace fgc {
namespace {
// Formatting helper: milliamps to one decimal, for warning text that has to name
// both what was asked for and what the hardware can actually do.
std::string ma(double v) {
std::ostringstream os;
os.setf(std::ios::fixed);
os.precision(1);
os << v;
return os.str();
}
constexpr int kGsMin = 32; // below this the datasheet disallows GLOBALSCALER
constexpr int kGsMax = 255; // 0 would mean 256 (full scale) - never emitted
constexpr int kIrunMax = 31;
} // namespace
double tmcRmsCurrentMa(int global_scaler, int irun, double r_sense_ohm, double v_fs) {
if (r_sense_ohm <= 0.0) return 0.0;
const double gs = (global_scaler <= 0) ? 256.0 : static_cast<double>(global_scaler);
return (gs / 256.0) * ((irun + 1) / 32.0) * (v_fs / (r_sense_ohm + 0.02)) /
std::sqrt(2.0) * 1000.0;
}
TmcCurrentPlan planTmcCurrent(double r_sense_ohm, double run_ma, double homing_ma,
double v_fs) {
TmcCurrentPlan p;
if (r_sense_ohm <= 0.0) {
p.warnings.push_back("sense resistor must be positive, got " +
ma(r_sense_ohm) + " ohm");
return p;
}
if (run_ma <= 0.0) {
p.warnings.push_back("run current must be positive, got " + ma(run_ma) + " mA");
return p;
}
if (homing_ma <= 0.0) {
p.warnings.push_back("homing current must be positive, got " + ma(homing_ma) + " mA");
return p;
}
// The most the bridge can deliver: GLOBALSCALER 256, IRUN 31.
const double i_max = tmcRmsCurrentMa(0, kIrunMax, r_sense_ohm, v_fs);
// Pick GLOBALSCALER so the RUN current sits at IRUN 31.
double gs_ideal = 256.0 * run_ma / i_max;
if (gs_ideal > kGsMax) {
p.global_scaler = kGsMax;
p.warnings.push_back("run current " + ma(run_ma) + " mA is above the " + ma(i_max) +
" mA this driver can deliver with a " + ma(r_sense_ohm) +
" ohm sense resistor; using the maximum instead");
} else if (gs_ideal < kGsMin) {
// GLOBALSCALER 1..31 is forbidden, so a very small run current has to be
// reached by lowering IRUN instead, at some cost in microstep resolution.
p.global_scaler = kGsMin;
p.warnings.push_back("run current " + ma(run_ma) +
" mA needs GLOBALSCALER below the permitted minimum of 32; "
"holding it at 32 and reducing IRUN instead, which coarsens "
"microstepping");
} else {
p.global_scaler = static_cast<int>(std::lround(gs_ideal));
}
// Full-scale current at the chosen scaler, i.e. what IRUN 31 now means.
const double full = tmcRmsCurrentMa(p.global_scaler, kIrunMax, r_sense_ohm, v_fs);
auto irunFor = [&](double target) {
return std::clamp(static_cast<int>(std::lround(32.0 * target / full)) - 1, 0, kIrunMax);
};
p.run_irun = irunFor(run_ma);
if (homing_ma > run_ma) {
// Not a feature: a homing current above the run current defeats the point
// of having a separate one.
p.homing_irun = p.run_irun;
p.warnings.push_back("homing current " + ma(homing_ma) +
" mA is above the run current " + ma(run_ma) +
" mA; clamping the search to the run current");
} else {
p.homing_irun = irunFor(homing_ma);
if (p.homing_irun > p.run_irun) p.homing_irun = p.run_irun;
}
p.run_ma = tmcRmsCurrentMa(p.global_scaler, p.run_irun, r_sense_ohm, v_fs);
p.homing_ma = tmcRmsCurrentMa(p.global_scaler, p.homing_irun, r_sense_ohm, v_fs);
// The smallest non-zero step this scaler can express. Worth naming when a
// homing target lands under it, because the result is then noticeably
// stronger than asked for - the direction that matters for a yielding stop.
const double step = full / 32.0;
if (p.homing_irun == 0 && homing_ma < step * 0.5) {
p.warnings.push_back("homing current " + ma(homing_ma) +
" mA is below the smallest step this driver can express (" +
ma(step) + " mA); using " + ma(p.homing_ma) + " mA");
}
auto driftWarn = [&](const char* what, double want, double got) {
if (want > 0.0 && std::fabs(got - want) / want > 0.10)
p.warnings.push_back(std::string(what) + " current " + ma(want) +
" mA is not reachable; nearest is " + ma(got) + " mA");
};
driftWarn("run", run_ma, p.run_ma);
if (homing_ma <= run_ma) driftWarn("homing", homing_ma, p.homing_ma);
p.ok = true;
return p;
}
} // namespace fgc

View File

@ -29,6 +29,11 @@ bool I2cBus::open() {
return true;
}
bool I2cBus::functionality(unsigned long& funcs) const {
if (fd_ < 0) return false;
return ::ioctl(fd_, I2C_FUNCS, &funcs) >= 0;
}
void I2cBus::close() {
if (fd_ >= 0) {
::close(fd_);

View File

@ -78,6 +78,9 @@ struct SerialMotorController::Impl {
std::string latest_diag;
std::atomic<unsigned> diag_seq{0};
// NEED capture: single lines, queued for the control thread to answer once.
std::vector<std::string> need_lines;
// Write one command line (newline-terminated) to the controller. Used by
// sendCommand and by the internal DUMP re-request.
//
@ -137,13 +140,24 @@ struct SerialMotorController::Impl {
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
LOG_WARN << "Motor controller: " << line;
} else if (!line.empty()) {
// OK acks and other async output (DG/DUMP/BOOT/...).
// OK acks and other async output (DG/DUMP/BOOT/NEED/...).
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
captureDump(line);
captureDiag(line);
captureNeed(line);
}
}
// Queue a NEED line for the control thread to answer. Unlike DUMP and DIAG
// this is not a block to assemble - each line stands alone and is actionable
// immediately, so it is queued rather than accumulated, and taking it clears
// it. A firmware that re-asks (after a RESET, say) simply queues again.
void captureNeed(const std::string& line) {
if (line.rfind("NEED ", 0) != 0) return;
std::lock_guard<std::mutex> lock(mutex);
need_lines.push_back(line);
}
// Assemble the DIAG stream. DG lines arrive interleaved with ST over many
// seconds, so we accumulate only "DG " lines and log each live; on "DG DONE"
// we publish the block and bump diag_seq so the app can pick up the result.
@ -281,6 +295,13 @@ std::string SerialMotorController::lastDiag() {
unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); }
std::vector<std::string> SerialMotorController::takeNeedLines() {
std::lock_guard<std::mutex> lock(impl_->mutex);
std::vector<std::string> out;
out.swap(impl_->need_lines);
return out;
}
bool SerialMotorController::connected() const { return impl_->connected; }
} // namespace fgc

View File

@ -1,5 +1,7 @@
#include "fgc/ui/TuiUi.h"
#include <ctime>
#include "fgc/ChannelTopics.h"
#include "fgc/DumpParser.h"
@ -909,7 +911,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
// (incl. RAMP_STAT, the last one) is visible without scrolling.
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
char letter, bool calib_has, const CalibAxisView& cal,
const DiagAxisView* diag, ScrollState& st) {
const AxisMapView& live_map, const DiagAxisView* diag, ScrollState& st) {
auto fmt = [](const char* f, double v) {
char b[32];
std::snprintf(b, sizeof(b), f, v);
@ -957,13 +959,21 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
// Last calibration result for this axis.
col.push_back(separator());
col.push_back(text("CALIBRATION") | bold | color(Color::Magenta));
// The values actually in use, always. They are what every bearing is computed
// from, so showing nothing here because no fit happened today told the
// operator the axis was uncalibrated when it was not.
col.push_back(kvRow("cnt/deg", fmt("%.3f", live_map.counts_per_deg)));
col.push_back(kvRow("deg@ctr", fmt("%.3f", live_map.deg_at_center)));
if (calib_has && cal.ok) {
col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg)));
col.push_back(kvRow("deg@ctr", fmt("%.3f", cal.deg_at_center)));
// A fit ran this session: add its quality, and flag if the live map has
// since moved away from it (a homing derivation, or a manual offset).
col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow));
col.push_back(kvRow("points", std::to_string(cal.n)));
col.push_back(text("from a fit this session") | dim);
} else if (calib_has) {
col.push_back(text("last fit this session FAILED") | color(Color::Yellow));
} else {
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim);
col.push_back(text("from config, no fit this session") | dim);
}
// Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the
@ -982,10 +992,19 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
return nullptr;
};
std::string reset;
// The firmware's own name for the reset cause wins. reset_flags is an AVR
// MCUSR bit decode, and on the RP2040 that field is getResetReason() - a
// plain enum - so decoding it as bits showed every post-flash soft reset here
// as "PORF (power-on) EXTRF (external)". Only the board knows its encoding.
std::string reset = d.reset_text;
if (reset.empty()) {
for (size_t i = 0; i < d.reset_flags.size(); ++i)
reset += (i ? " " : "") + d.reset_flags[i];
std::string calib_note = "uncalibrated this session";
// Firmware predating `reset=`: the decode is an assumption, so say so
// rather than presenting it as fact.
if (!reset.empty()) reset += "?";
}
std::string calib_note = "calibration from config";
if (calib.has) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
@ -1002,10 +1021,11 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
});
std::vector<Element> cols;
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'), st[0]));
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, calib.live_yaw,
diagFor('Y'), st[0]));
if (g.pitch_present)
cols.push_back(
axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'), st[1]));
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch,
calib.live_pitch, diagFor('P'), st[1]));
return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]),
vbox({header, separator(), hbox(std::move(cols)) | flex}));
@ -1049,7 +1069,7 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
| color(Color::Magenta));
if (!v.present) {
body.push_back(text("(no IMU data)") | color(Color::Red) | bold);
body.push_back(text("check [Features] enable_imu and [IMU] device, or --mock-imu") | dim);
body.push_back(text("declare [Hardware] imu and set [IMU] device, or --mock-imu") | dim);
} else {
body.push_back(hbox({
text("ORIENTATION") | dim | size(WIDTH, EQUAL, 14),
@ -1094,7 +1114,7 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
if (!e.enabled) {
body.push_back(text("(env sensor disabled)") | dim);
body.push_back(
text("enable with [Features] enable_env and [Env] i2c_device, or --mock-env")
text("declare [Hardware] env and set [Env] i2c_device, or --mock-env")
| dim);
} else if (!e.present) {
body.push_back(text("(no reading)") | color(Color::Red) | bold);
@ -1238,13 +1258,30 @@ void TuiUi::pushLog(LogLevel level, const std::string& line) {
}
}
// Wall-clock time the frame was frozen at, "HH:MM:SS". Captured once, because
// the badge must not need a repaint to stay truthful - repainting would unfreeze
// the very data the pause is holding still.
std::string TuiUi::nowHms() {
const auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm tm{};
localtime_r(&t, &tm);
char buf[16];
std::strftime(buf, sizeof(buf), "%H:%M:%S", &tm);
return buf;
}
void TuiUi::refreshLoop() {
using namespace std::chrono_literals;
auto last_dump = std::chrono::steady_clock::now();
while (running_) {
std::this_thread::sleep_for(100ms);
if (!running_) break;
if (paused_) continue; // frozen for text selection; don't repaint
// Frozen for text selection: do NOT repaint. Re-rendering would pull the
// live snapshot and the numbers would keep moving under the cursor,
// which is precisely what the freeze exists to prevent. The badge
// carries the wall-clock time of the freeze instead, so the frame says
// how old it is without having to be redrawn to say it.
if (paused_) continue;
// While the gimbal overlay is open, re-pull a firmware dump every ~3s so
// the encoder-tracking counters stay current without pressing 'd'.
if (gimbal_overlay_open_.load() && sink_ &&
@ -1323,7 +1360,15 @@ void TuiUi::uiLoop() {
text(" " + s.header.tower + " ") | bold,
text("build " + s.header.build) | dim,
filler(),
(paused_ ? text(" PAUSED ") | color(Color::Black) | bgcolor(Color::Yellow) | bold
// A frozen frame has to say that it is frozen, and for how long.
// Without the age it is indistinguishable from a hung program: the
// numbers simply stop, which during a multi-minute home reads as a
// crash. Costing 15 minutes of "is it stuck?" is how this got added.
// Says WHEN it froze, not just that it is frozen. Without the time a
// held frame is indistinguishable from a hung program - the numbers
// simply stop - which during a multi-minute home reads as a crash.
(paused_ ? text(" PAUSED @" + paused_at_hms_ + " ")
| color(Color::Black) | bgcolor(Color::Yellow) | bold
: text("")),
text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold,
text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
@ -1640,6 +1685,7 @@ void TuiUi::uiLoop() {
}
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
paused_ = !paused_;
if (paused_) paused_at_hms_ = nowHms();
return true; // this keypress redraws once so the indicator updates
}
if (overlay == Overlay::Help) { // vim-style section nav while help is open

View File

@ -13,6 +13,7 @@ const char* axisStateLabel(AxisState s) {
case AxisState::Homing: return "HOMING";
case AxisState::Ready: return "READY";
case AxisState::Error: return "ERROR";
case AxisState::NeedConfig: return "NEEDCFG";
case AxisState::Unknown:
default: return "----";
}
@ -23,6 +24,9 @@ UiColor axisStateColor(AxisState s) {
case AxisState::Ready:
case AxisState::Homing: return UiColor::Cyan;
case AxisState::Error: return UiColor::Red;
// Not red: nothing is broken, the firmware is waiting to be told what it
// is driving. Yellow so it reads as "needs you", not as a fault.
case AxisState::NeedConfig: return UiColor::Yellow;
case AxisState::Boot:
case AxisState::Reset: return UiColor::Dim;
case AxisState::Unknown:

View File

@ -22,6 +22,11 @@ add_executable(fgc_tests
test_paths.cpp
test_singleinstance.cpp
test_config.cpp
test_tmccurrent.cpp
test_hardwarepresence.cpp
test_hardwarechecks.cpp
test_axisspeed.cpp
test_firmwareconfig.cpp
test_cameraprofile.cpp
test_camerasettingsxml.cpp
test_hash.cpp

113
tests/test_axisspeed.cpp Normal file
View File

@ -0,0 +1,113 @@
#include "doctest/doctest.h"
#include "fgc/AxisSpeed.h"
using namespace fgc;
namespace {
// The two real drivetrains. These numbers are the whole point of the module:
// the same VMAX means different things on them.
constexpr double kDelta = 983.33; // tower 111 yaw, counts/deg
constexpr double kIota = 3552.87; // tower 121 yaw, counts/deg (derived from the homed travel)
} // namespace
TEST_CASE("the clock constant matches what was measured on the rig") {
// Three homing runs on tower 121 put it at ~0.73 counts/s per VMAX unit; the
// datasheet's f_CLK/2^24 predicts 0.7153. If this drifts, every speed does.
const double per_unit = vmaxToDegPerSec(1, 1.0); // counts/s for counts_per_deg == 1
CHECK(per_unit == doctest::Approx(0.7153).epsilon(0.001));
CHECK(per_unit == doctest::Approx(0.73).epsilon(0.03));
}
TEST_CASE("the same VMAX is a different speed on each tower") {
// This is the bug the module exists to remove: both towers carry 30000.
CHECK(vmaxToDegPerSec(30000, kDelta) == doctest::Approx(21.8).epsilon(0.01));
CHECK(vmaxToDegPerSec(30000, kIota) == doctest::Approx(6.04).epsilon(0.01));
// And the host literal that was overriding the firmware on the Delta fleet.
CHECK(vmaxToDegPerSec(50000, kDelta) == doctest::Approx(36.4).epsilon(0.01));
}
TEST_CASE("the fleet default resolves on both drivetrains") {
auto d = planAxisSpeed(kDefaultScanSpeedDegPerSec, kDelta);
REQUIRE(d.ok);
CHECK(d.vmax == 10998);
CHECK(d.actual_deg_s == doctest::Approx(8.0).epsilon(0.001));
auto i = planAxisSpeed(kDefaultScanSpeedDegPerSec, kIota);
REQUIRE(i.ok);
CHECK(i.vmax == 39738);
CHECK(i.actual_deg_s == doctest::Approx(8.0).epsilon(0.001));
// Same physical speed, VMAX differing by the gear ratio - which is the whole
// argument for declaring deg/s.
CHECK(static_cast<double>(i.vmax) / d.vmax == doctest::Approx(kIota / kDelta).epsilon(0.001));
}
TEST_CASE("a round trip is stable to the integer quantisation") {
for (double want : {2.0, 8.0, 21.8, 36.4}) {
auto p = planAxisSpeed(want, kIota);
REQUIRE(p.ok);
CHECK(p.actual_deg_s == doctest::Approx(want).epsilon(0.001));
}
}
TEST_CASE("direction sense belongs to Geometry, not to a speed") {
// counts_per_deg may be negative to flip the axis; VMAX must not go negative.
auto p = planAxisSpeed(8.0, -kDelta);
REQUIRE(p.ok);
CHECK(p.vmax > 0);
CHECK(p.vmax == planAxisSpeed(8.0, kDelta).vmax);
}
TEST_CASE("an uncalibrated axis is refused, and the message says how to fix it") {
auto p = planAxisSpeed(8.0, 0.0);
CHECK_FALSE(p.ok);
CHECK(p.error.find("counts_per_deg") != std::string::npos);
CHECK(p.error.find("travel_deg") != std::string::npos);
}
TEST_CASE("AxisMap's uncalibrated default is refused, not quietly obeyed") {
// The real bug this caught: an un-calibrated pitch sits at counts_per_deg
// 1.0, which is > 0 and so passes a naive guard. 8 deg/s through it yields
// VMAX 11 - an axis that looks configured and never finishes a sweep.
auto p = planAxisSpeed(8.0, 1.0);
CHECK_FALSE(p.ok);
CHECK(p.error.find("not a calibrated drivetrain") != std::string::npos);
// The floor is the encoder's own ungeared resolution, so a real axis - even
// a direct-drive one - is never rejected.
CHECK(planAxisSpeed(8.0, 11.2).ok);
}
TEST_CASE("a nonsensical speed is refused rather than silently substituted") {
CHECK_FALSE(planAxisSpeed(0.0, kDelta).ok);
CHECK_FALSE(planAxisSpeed(-5.0, kDelta).ok);
}
TEST_CASE("a speed below one VMAX unit fails instead of creeping") {
// Substituting the slowest commandable speed would look like a working axis
// that never finishes a sweep.
auto p = planAxisSpeed(0.00001, kDelta);
CHECK_FALSE(p.ok);
CHECK(p.error.find("slowest commandable") != std::string::npos);
}
TEST_CASE("a speed beyond the register is clamped, and says so") {
// 23-bit VMAX. On the IOTA's gear ratio this is reachable with a plausible
// typo, so it must clamp rather than wrap.
auto p = planAxisSpeed(5000.0, kIota);
REQUIRE(p.ok);
CHECK(p.clamped);
CHECK(p.vmax == kTmcVmaxMax);
// The reported speed is what the axis will really do, not what was asked.
CHECK(p.actual_deg_s < 5000.0);
}
TEST_CASE("tower 121 cannot match tower 111's old speed, and the plan shows it") {
// 36.4 deg/s on the IOTA needs VMAX ~181k - expressible, but 6x its current
// 30000. Declaring deg/s is what makes that visible instead of mysterious.
auto p = planAxisSpeed(36.4, kIota);
REQUIRE(p.ok);
CHECK(p.vmax > 180000);
CHECK(p.vmax < 182000);
}

View File

@ -12,6 +12,20 @@
using namespace fgc;
namespace {
// How many times `needle` appears in `hay`. Used to assert that calibration
// history replaces itself rather than accumulating.
int countOccurrences(const std::string& hay, const std::string& needle) {
int n = 0;
size_t i = hay.find(needle);
while (i != std::string::npos) {
++n;
i = hay.find(needle, i + needle.size());
}
return n;
}
} // namespace
TEST_CASE("ConfigLoader maps and defaults typed values") {
unsetenv("FGC_MQTT_USER");
unsetenv("FGC_MQTT_PW");
@ -260,6 +274,90 @@ TEST_CASE("updateIniSectionKeys appends keys missing from the section") {
CHECK(out.find("pitch_center_count = 7") != std::string::npos);
}
TEST_CASE("updateIniSectionKeys keeps the superseded value as a ';prev' comment") {
const std::string in =
"[Motor]\n"
"; the heading the gimbal points at when parked\n"
"yaw_heading_at_center = 0.0\n"
"yaw_center_count = 500000\n";
auto out = updateIniSectionKeys(in, "Motor",
{{"yaw_heading_at_center", "127.4"},
{"yaw_center_count", "500000"}}, // unchanged
"2026-09-11T14:32Z, gimbal heading setup");
SUBCASE("the changed key keeps its old value, stamped") {
CHECK(out.find(";prev yaw_heading_at_center = 0.0") != std::string::npos);
CHECK(out.find("2026-09-11T14:32Z, gimbal heading setup") != std::string::npos);
CHECK(out.find("yaw_heading_at_center = 127.4") != std::string::npos);
}
SUBCASE("the history sits directly above the value it superseded") {
CHECK(out.find(";prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal "
"heading setup\nyaw_heading_at_center = 127.4") != std::string::npos);
}
SUBCASE("an UNCHANGED key gets no history - otherwise every save adds noise") {
CHECK(out.find(";prev yaw_center_count") == std::string::npos);
}
SUBCASE("hand-written comments survive") {
CHECK(out.find("; the heading the gimbal points at when parked") != std::string::npos);
}
}
TEST_CASE("updateIniSectionKeys keeps exactly one generation of history") {
// Saving twice must REPLACE the marker, not stack markers up: the file has to
// grow once and then hold steady however often the gimbal is recalibrated.
std::string s = "[Motor]\nyaw_heading_at_center = 0.0\n";
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "10"}}, "t1, first");
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "20"}}, "t2, second");
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "30"}}, "t3, third");
CHECK(countOccurrences(s, ";prev yaw_heading_at_center") == 1);
CHECK(s.find(";prev yaw_heading_at_center = 20") != std::string::npos); // the one before
CHECK(s.find("yaw_heading_at_center = 30") != std::string::npos);
CHECK(s.find("= 0.0") == std::string::npos); // the oldest is gone
CHECK(s.find("t3, third") != std::string::npos);
CHECK(s.find("t1, first") == std::string::npos);
}
TEST_CASE("updateIniSectionKeys history respects inline comments and unchanged values") {
// inih treats ' ;' as an inline comment, so the stored value is "983.33", not
// "983.33 ; measured". Comparing raw text instead would call this key changed
// on every save and bury the file in useless history.
const std::string in = "[Motor]\nyaw_counts_per_deg = 983.33 ; measured on the rig\n";
SUBCASE("same value behind an inline comment counts as unchanged") {
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "983.33"}}, "t, c");
CHECK(out.find(";prev") == std::string::npos);
}
SUBCASE("a real change records only the parsed value, not the comment") {
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "1000"}}, "t, c");
CHECK(out.find(";prev yaw_counts_per_deg = 983.33 ; t, c") != std::string::npos);
}
}
TEST_CASE("updateIniSectionKeys writes no history without a provenance") {
// The default keeps the old behaviour, so callers that have nothing useful to
// say about where the values came from do not litter the file.
const std::string in = "[Motor]\nyaw_center_count = 1\n";
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_center_count", "2"}});
CHECK(out.find(";prev") == std::string::npos);
CHECK(out.find("yaw_center_count = 2") != std::string::npos);
}
TEST_CASE("a ';prev' comment does not survive a reload as a real key") {
// The marker has to be inert to inih, or reverting by uncommenting would be
// pointless: the stale value would already be in force.
std::string s = "[General]\ntower_name = T\n[Motor]\nyaw_center_count = 1\n";
s = updateIniSectionKeys(s, "Motor", {{"yaw_center_count", "2"}}, "t, c");
REQUIRE(s.find(";prev yaw_center_count = 1") != std::string::npos);
const std::string path = "test_prev_marker_reload.ini";
{ std::ofstream(path) << s; }
auto cfg = ConfigLoader::loadFromFile(path);
CHECK(cfg.geometry.yaw.center_count == 2);
std::filesystem::remove(path);
}
TEST_CASE("saveMotorGeometry round-trips the full per-axis map") {
// Write a starting config, save a calibrated Geometry into it, reload, and
// confirm every [Motor] parameter survives, while another section is left
@ -401,6 +499,89 @@ TEST_CASE("[IMU] x/y/z describe the mount and default to no rotation") {
CHECK_FALSE(c.imu.mount.identity());
}
TEST_CASE("hardware.ini layers over config.ini and wins") {
const std::string cfg_path = "test_hw_layer_config.ini";
const std::string hw_path = "test_hw_layer_hardware.ini";
{
std::ofstream c(cfg_path);
c << "[General]\ntower_name = NorthRidge\nimage_interval = 3\n"
"[Camera.rgb]\nid = OLD_ID\nprofile_dir = /tmp/p\n"
"[Serial]\ndevice = /dev/ttyACM0\n";
std::ofstream h(hw_path);
h << "[Board]\nmodel = lattepanda-iota\nfirmware_env = iota_rp2040\n"
"[Hardware]\naxes = yaw, pitch\nimu = none\nenv = sht41\ncameras = rgb\n"
"[Serial]\ndevice = /dev/serial/by-id/usb-RP2040-if00\n"
"[Camera.rgb]\nid = NEW_ID\n";
}
auto c = ConfigLoader::loadFromFile(cfg_path, hw_path);
SUBCASE("assembly data arrives from hardware.ini") {
CHECK(c.board.model == "lattepanda-iota");
CHECK(c.board.firmware_env == "iota_rp2040");
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
CHECK(c.hardware.imu == "none");
CHECK(c.hardware.env == "sht41");
CHECK(c.hardware.cameras == std::vector<std::string>{"rgb"});
CHECK(c.hardware.declared);
CHECK(c.hardware.source_path == hw_path);
}
SUBCASE("bindings in hardware.ini override the ones left in config.ini") {
CHECK(c.serial.device == "/dev/serial/by-id/usb-RP2040-if00");
REQUIRE(c.camera.cameras.size() == 1);
CHECK(c.camera.cameras[0].id == "NEW_ID");
}
SUBCASE("operational settings stay with config.ini") {
CHECK(c.general.tower_name == "NorthRidge");
CHECK(c.general.image_interval == 3);
}
std::filesystem::remove(cfg_path);
std::filesystem::remove(hw_path);
}
TEST_CASE("without a hardware.ini nothing changes and nothing is declared") {
// The migration guarantee: an un-migrated tower must behave exactly as before.
const std::string path = "test_hw_absent.ini";
{
std::ofstream c(path);
c << "[General]\ntower_name = Old\n"
"[Camera.rgb]\nid = D\nprofile_dir = /tmp/p\n"
"[Serial]\ndevice = /dev/ttyACM0\n";
}
auto c = ConfigLoader::loadFromFile(path);
CHECK_FALSE(c.hardware.declared);
CHECK(c.hardware.axes.empty());
CHECK(c.serial.device == "/dev/ttyACM0");
CHECK(c.general.tower_name == "Old");
// An undeclared rig must not answer "no" to any axis, or every caller that
// asks would start refusing work on towers that have not been migrated.
CHECK(c.hardware.hasAxis("yaw"));
CHECK(c.hardware.hasAxis("pitch"));
std::filesystem::remove(path);
}
TEST_CASE("Hardware.axes parsing and membership") {
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", " YAW ,, pitch ,"}});
SUBCASE("case, spacing and stray commas are tolerated") {
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
CHECK(c.hardware.axesLabel() == "yaw, pitch");
}
SUBCASE("a declared yaw-only rig answers no to pitch") {
auto y = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", "yaw"}});
CHECK(y.hardware.hasAxis("yaw"));
CHECK_FALSE(y.hardware.hasAxis("pitch"));
}
SUBCASE("an unknown axis is a typo worth refusing, not ignoring") {
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", "yaw, roll"}}));
}
}
TEST_CASE("A mount angle that cannot be a rotation is rejected") {
auto with = [](const std::string& k, const std::string& v) {
return ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
@ -415,3 +596,142 @@ TEST_CASE("A mount angle that cannot be a rotation is rejected") {
CHECK_NOTHROW(with("IMU.z", "-360"));
CHECK_NOTHROW(with("IMU.z", "360"));
}
TEST_CASE("[Motor.<axis>] drivetrain keys are parsed per axis") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
{"Motor.yaw.homing_current_ma", "200"},
{"Motor.yaw.travel_deg", "340"},
});
CHECK(cfg.drivetrain.yaw.declared);
CHECK(cfg.drivetrain.yaw.r_sense_ohm == doctest::Approx(0.075));
CHECK(cfg.drivetrain.yaw.run_current_ma == doctest::Approx(756.0));
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(200.0));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
CHECK(cfg.drivetrain.yaw.reducesForHoming());
// The other axis stays undeclared, so nothing is sent for it.
CHECK_FALSE(cfg.drivetrain.pitch.declared);
}
TEST_CASE("an axis that names only a run current is not asking for a reduction") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
});
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(756.0));
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("[Motor.yaw] travel_deg does not shadow [Motor] yaw_counts_per_deg") {
// The whole reason the travel angle is a separate key in a separate section:
// hardware.ini is merged OVER config.ini and wins, so if the derived
// calibration shared a key with the nameplate fact, every saved calibration
// would be silently discarded on the next start.
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw_counts_per_deg", "3561.1"}, // calibration, from config.ini
{"Motor.yaw.travel_deg", "340"}, // nameplate, from hardware.ini
});
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(3561.1));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
}
TEST_CASE("a drivetrain current without its sense resistor is refused") {
// Computing registers against a guessed resistor would silently deliver the
// wrong current, which on a rig with a yielding endstop is the whole hazard.
CHECK_THROWS(ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.run_current_ma", "756"},
}));
}
TEST_CASE("an undeclared drivetrain leaves every axis silent") {
auto cfg = ConfigLoader::fromMap({{"General.tower_name", "t"}});
CHECK_FALSE(cfg.drivetrain.yaw.declared);
CHECK_FALSE(cfg.drivetrain.pitch.declared);
CHECK(cfg.drivetrain.yaw.run_current_ma == 0.0);
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("a value that only changes spelling gets no history line") {
// The writer formats doubles with %.10g, so a hand-written "0.0" comes back
// as "0". That is the same number, and recording it as a change would spend
// the single ";prev" generation on a value that never moved - displacing the
// real previous value the mechanism exists to preserve.
const std::string before = "[Motor]\nyaw_heading_at_center = 0.0\n";
const std::string after =
updateIniSectionKeys(before, "Motor", {{"yaw_heading_at_center", "0"}}, "a save");
CHECK(after.find(";prev") == std::string::npos);
CHECK(after.find("yaw_heading_at_center = 0") != std::string::npos);
}
TEST_CASE("differently spelled equal numbers are not changes") {
for (auto [old_text, new_text] : std::vector<std::pair<std::string, std::string>>{
{"1e3", "1000"}, {"500.0", "500"}, {"0.50", "0.5"}, {" 12 ", "12"}}) {
const std::string out = updateIniSectionKeys("[Motor]\nk = " + old_text + "\n", "Motor",
{{"k", new_text}}, "a save");
CHECK(out.find(";prev") == std::string::npos);
}
}
TEST_CASE("a genuine numeric change still records its history") {
const std::string out = updateIniSectionKeys("[Motor]\nyaw_counts_per_deg = 500\n", "Motor",
{{"yaw_counts_per_deg", "3552.867647"}},
"homed travel");
CHECK(out.find(";prev yaw_counts_per_deg = 500") != std::string::npos);
CHECK(out.find("homed travel") != std::string::npos);
CHECK(out.find("yaw_counts_per_deg = 3552.867647") != std::string::npos);
}
TEST_CASE("non-numeric values still compare as text") {
const std::string out = updateIniSectionKeys("[Serial]\ndevice = /dev/ttyACM0\n", "Serial",
{{"device", "/dev/ttyUSB0"}}, "a save");
CHECK(out.find(";prev device = /dev/ttyACM0") != std::string::npos);
}
// --- [Hardware] imu/env retiring [Features] enable_imu/enable_env (plan A.12) ---
TEST_CASE("an undeclared rig still obeys the legacy flag exactly as before") {
// Every tower until it is migrated. Behaviour must not change.
auto on = resolveSensorEnable("", true, true);
CHECK(on.enabled);
CHECK(on.source == SensorEnableSource::Flag);
CHECK_FALSE(on.conflict);
auto off = resolveSensorEnable("", false, true);
CHECK_FALSE(off.enabled);
auto absent = resolveSensorEnable("", false, false);
CHECK_FALSE(absent.enabled);
CHECK(absent.source == SensorEnableSource::Default);
}
TEST_CASE("a declared sensor needs no second key agreeing with it") {
// The trap this removes: declare the IMU, forget enable_imu, and presence
// reconciliation reports missing HARDWARE for what is a missing flag.
auto e = resolveSensorEnable("mti-630", /*flag_value=*/false, /*flag_present=*/false);
CHECK(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK_FALSE(e.conflict);
}
TEST_CASE("'none' cannot be contradicted into enabling a sensor") {
auto e = resolveSensorEnable("none", /*flag_value=*/true, /*flag_present=*/true);
CHECK_FALSE(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK(e.conflict); // named, so the stale key can be deleted
}
TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported") {
auto e = resolveSensorEnable("sht41", /*flag_value=*/false, /*flag_present=*/true);
CHECK(e.enabled);
CHECK(e.conflict);
// Agreeing is not a conflict, merely redundant.
auto agree = resolveSensorEnable("sht41", true, true);
CHECK(agree.enabled);
CHECK_FALSE(agree.conflict);
}

View File

@ -136,3 +136,127 @@ TEST_CASE("formatDump renders valid and invalid dumps") {
CHECK(joined.find("[P]") != std::string::npos);
CHECK(joined.find("RAMP_STAT") != std::string::npos);
}
TEST_CASE("a DUMP axis line carries the motor current the firmware applied") {
auto d = parseDump(
"DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n"
"DUMP Y state=3 hsub=0 enabled=1 lim_neg=-105389 lim_pos=1105389 hold_target=0 "
"speed=3000 center=500000 soft_margin=5000 gs=80 irun=31 irun_home=7 "
"hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n");
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].global_scaler == 80);
CHECK(d.axes[0].run_irun == 31);
CHECK(d.axes[0].homing_irun == 7);
}
TEST_CASE("a DUMP from firmware without the current fields reports -1, not zero") {
// Zero is a legitimate IRUN; absent is a version skew. Conflating them would
// let an old firmware look like one configured to zero current.
auto d = parseDump(
"DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n"
"DUMP Y state=3 hsub=0 enabled=1 lim_neg=0 lim_pos=0 hold_target=0 speed=3000 "
"center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n");
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].global_scaler == -1);
CHECK(d.axes[0].run_irun == -1);
CHECK(d.axes[0].homing_irun == -1);
}
TEST_CASE("the host-owned drivetrain values are read back from DUMP") {
const std::string text =
"DUMP BEGIN build=abc uptime=1 mcusr=0x01 free_ram=4096\n"
"DUMP Y state=2 hsub=0 enabled=1 lim_neg=0 lim_pos=1000000 hold_target=0 speed=39738 "
"center=500000 soft_margin=5000 gs=80 irun=31 irun_home=7 steps_per_rev=1210778 "
"enc_dev=1500 homing_speed=9958 center_speed=29873 sw_mode=0 cfg_rx=0xFFE "
"hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n";
auto d = fgc::parseDump(text);
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
const auto& a = d.axes[0];
CHECK(a.steps_per_rev == 1210778);
CHECK(a.enc_deviation == 1500);
CHECK(a.homing_speed == 9958);
CHECK(a.center_speed == 29873);
CHECK(a.sw_mode == 0);
CHECK(a.cfg_received == 0xFFE);
}
TEST_CASE("a firmware predating the handshake leaves those fields absent, not zero") {
// -1 keeps "did not say" distinguishable from "said zero" - sw_mode 0 is a
// legitimate setting on the yaw axis, so zero-as-absent would be a real bug.
const std::string text =
"DUMP BEGIN build=old uptime=1 mcusr=0x01 free_ram=4096\n"
"DUMP Y state=2 hsub=0 enabled=1 lim_neg=0 lim_pos=1000000 hold_target=0 speed=200000 "
"center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n";
auto d = fgc::parseDump(text);
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].steps_per_rev == -1);
CHECK(d.axes[0].sw_mode == -1);
CHECK(d.axes[0].cfg_received == -1);
}
TEST_CASE("the numeric state codes did not shift when NEEDCFG was added") {
// NEEDCFG is appended (5), not inserted in lifecycle order, because these
// numbers are on the wire. Had it gone in at 1, a READY axis (which would
// have become 4) would read back as ERROR - a healthy tower reporting a
// fault in every dump, and an old firmware misread by a new host.
auto one = [](int n) {
const std::string text =
"DUMP BEGIN build=x uptime=1 mcusr=0x01 free_ram=1\n"
"DUMP Y state=" + std::to_string(n) +
" hsub=0 enabled=1 lim_neg=0 lim_pos=1 hold_target=0 speed=1 center=0 "
"soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n";
return fgc::parseDump(text).axes.at(0).state_name;
};
CHECK(one(0) == "BOOT");
CHECK(one(1) == "RESET");
CHECK(one(2) == "HOMING");
CHECK(one(3) == "READY");
CHECK(one(4) == "ERROR");
CHECK(one(5) == "NEEDCFG");
}
TEST_CASE("the firmware's own name for the reset cause wins over any decode") {
// mcusr means different things per board: an AVR MCUSR bitmask, or the
// RP2040's getResetReason() enum. 0x3 is "two bits set" on one and a single
// enum value on the other, so a host-side table cannot be right for both.
const std::string text =
"DUMP BEGIN build=x uptime=1 mcusr=0x3 reset=SOFT_RESET free_ram=4096\n"
"DUMP Y state=1 hsub=0 enabled=0 lim_neg=0 lim_pos=0 hold_target=0 speed=1 center=0 "
"soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n";
auto d = fgc::parseDump(text);
REQUIRE(d.valid);
CHECK(d.reset_text == "SOFT_RESET");
std::string out;
for (const auto& l : fgc::formatDump(d)) out += l + "\n";
CHECK(out.find("SOFT_RESET") != std::string::npos);
// The AVR names must not appear when the firmware said what it was.
CHECK(out.find("PORF") == std::string::npos);
CHECK(out.find("EXTRF") == std::string::npos);
}
TEST_CASE("without that field the AVR decode is labelled as an assumption") {
const std::string text =
"DUMP BEGIN build=old uptime=1 mcusr=0x3 free_ram=4096\n"
"DUMP Y state=1 hsub=0 enabled=0 lim_neg=0 lim_pos=0 hold_target=0 speed=1 center=0 "
"soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n";
auto d = fgc::parseDump(text);
REQUIRE(d.valid);
CHECK(d.reset_text.empty());
std::string out;
for (const auto& l : fgc::formatDump(d)) out += l + "\n";
CHECK(out.find("PORF") != std::string::npos); // still decoded
CHECK(out.find("assumed AVR") != std::string::npos); // but not stated as fact
}

View File

@ -0,0 +1,265 @@
#include "doctest/doctest.h"
#include "fgc/FirmwareConfig.h"
#include <algorithm>
using namespace fgc;
namespace {
// Tower 121's yaw, fully declared: the case the handshake exists to serve.
AxisConfigSource iotaYaw() {
AxisConfigSource s;
s.have_current = true;
s.global_scaler = 80;
s.run_irun = 31;
s.homing_irun = 7;
s.center_count = 500000;
s.soft_margin = 5000;
s.have_travel = true;
s.steps_per_rev = 1210778;
s.have_deviation = true;
s.enc_deviation = 1500;
s.normal_vmax = 39738; // 8 deg/s at 3552.87 counts/deg
s.homing_vmax = 30000;
s.center_vmax = 25000;
s.have_wiring = true;
s.sw_mode = 0; // yaw: no physical stops, StallGuard homing
s.has_encoder = true;
return s;
}
bool sent(const FirmwareConfigPlan& p, const std::string& text) {
return std::find(p.sets.begin(), p.sets.end(), text) != p.sets.end();
}
} // namespace
TEST_CASE("a NEED line parses into an axis and its keys") {
auto r = parseNeedLine("NEED Y steps_per_rev global_scaler center margin");
REQUIRE(r);
CHECK(r->axis == 'Y');
REQUIRE(r->keys.size() == 4);
CHECK(r->keys[0] == "steps_per_rev");
CHECK(r->keys[3] == "margin");
auto p = parseNeedLine("NEED P sw_mode");
REQUIRE(p);
CHECK(p->axis == 'P');
}
TEST_CASE("an axis that needs nothing is well-formed, not a parse failure") {
// How a firmware built for fewer axes than the rig has answers for the axis
// it does not drive.
auto r = parseNeedLine("NEED P");
REQUIRE(r);
CHECK(r->axis == 'P');
CHECK(r->keys.empty());
CHECK(planFirmwareConfig(*r, iotaYaw()).complete());
}
TEST_CASE("anything that is not a NEED line is ignored") {
CHECK_FALSE(parseNeedLine("ST Y:R,0,0,00000000,0,0,0,S"));
CHECK_FALSE(parseNeedLine("NEEDCFG"));
CHECK_FALSE(parseNeedLine("NEED"));
CHECK_FALSE(parseNeedLine("NEED X steps_per_rev")); // not an axis
CHECK_FALSE(parseNeedLine("NEED YY steps_per_rev"));
CHECK_FALSE(parseNeedLine(""));
}
TEST_CASE("a fully declared axis is answered completely, in the order asked") {
auto r = parseNeedLine(
"NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed "
"center_speed center margin");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK(p.complete());
CHECK(p.unanswered.empty());
CHECK(p.unknown.empty());
REQUIRE(p.sets.size() == 9);
CHECK(p.sets.front() == "SET Y steps_per_rev 1210778");
CHECK(sent(p, "SET Y global_scaler 80"));
CHECK(sent(p, "SET Y enc_deviation 1500"));
CHECK(sent(p, "SET Y normal_speed 39738"));
CHECK(p.sets.back() == "SET Y margin 5000");
}
TEST_CASE("center/margin are accepted under both their names") {
auto legacy = parseNeedLine("NEED Y center margin");
auto full = parseNeedLine("NEED Y center_count soft_margin");
REQUIRE(legacy);
REQUIRE(full);
CHECK(planFirmwareConfig(*legacy, iotaYaw()).sets[0] == "SET Y center 500000");
CHECK(planFirmwareConfig(*full, iotaYaw()).sets[0] == "SET Y center_count 500000");
}
TEST_CASE("an undeclared current is unanswered, never sent as zero") {
// A silently zeroed current is a motor that cannot hold, so the key must go
// unanswered and leave the firmware in NEEDCFG.
auto s = iotaYaw();
s.have_current = false;
auto r = parseNeedLine("NEED Y global_scaler run_irun homing_irun");
REQUIRE(r);
auto p = planFirmwareConfig(*r, s);
CHECK_FALSE(p.complete());
CHECK(p.sets.empty());
CHECK(p.unanswered.size() == 3);
}
TEST_CASE("a speed that could not be converted is unanswered, not VMAX 0") {
// VMAX 0 is a stopped axis, not a slow one. AxisSpeed refuses an
// uncalibrated scale, and that refusal has to survive into the handshake.
auto s = iotaYaw();
s.normal_vmax = 0;
auto r = parseNeedLine("NEED Y normal_speed homing_speed");
REQUIRE(r);
auto p = planFirmwareConfig(*r, s);
CHECK_FALSE(p.complete());
REQUIRE(p.unanswered.size() == 1);
CHECK(p.unanswered[0] == "normal_speed");
CHECK(sent(p, "SET Y homing_speed 30000"));
}
TEST_CASE("a key this host has never heard of is a version skew, not a gap") {
// The two need different fixes - upgrade the host vs. fill in hardware.ini -
// so they are reported separately.
auto r = parseNeedLine("NEED Y steps_per_rev flux_capacitor");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK_FALSE(p.complete());
CHECK(p.unanswered.empty());
REQUIRE(p.unknown.size() == 1);
CHECK(p.unknown[0] == "flux_capacitor");
// The keys it DID understand are still answered.
CHECK(sent(p, "SET Y steps_per_rev 1210778"));
}
TEST_CASE("has_encoder is sent as 1/0, and follows the wiring declaration") {
auto s = iotaYaw();
s.has_encoder = false;
auto r = parseNeedLine("NEED Y has_encoder");
REQUIRE(r);
CHECK(planFirmwareConfig(*r, s).sets[0] == "SET Y has_encoder 0");
s.have_wiring = false;
CHECK_FALSE(planFirmwareConfig(*r, s).complete());
}
TEST_CASE("the pitch axis is addressed as P") {
auto r = parseNeedLine("NEED P center margin");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK(p.sets[0] == "SET P center 500000");
CHECK(p.sets[1] == "SET P margin 5000");
}
// --- End to end through the mock's NEEDCFG mode -----------------------------
#include "fgc/mock/MockMotorController.h"
TEST_CASE("the mock in NEEDCFG asks, and refuses to move until answered") {
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
CHECK(lines[0].rfind("NEED Y ", 0) == 0);
CHECK(lines[1].rfind("NEED P ", 0) == 0);
// Taking them clears the queue: they are a request, not a state to poll.
CHECK(m.takeNeedLines().empty());
// Before the answer, motion commands are rejected.
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
// Answer the way the host would.
AxisConfigSource src;
src.have_current = true;
src.global_scaler = 80;
src.center_count = 500000;
src.soft_margin = 5000;
src.have_travel = true;
src.steps_per_rev = 1210778;
src.have_deviation = true;
src.enc_deviation = 1500;
src.normal_vmax = 39738;
src.homing_vmax = 30000;
src.center_vmax = 25000;
src.have_wiring = true;
// Answer BOTH axes, as the host does - the axis unlocks only when every key
// it asked for has been answered.
for (const auto& l : lines) {
auto n = parseNeedLine(l);
REQUIRE(n);
auto plan = planFirmwareConfig(*n, src);
REQUIRE(plan.complete());
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
}
// The values landed, under the names the firmware asked for.
const auto got = m.settings();
CHECK(got.at("Y.steps_per_rev") == "1210778");
CHECK(got.at("Y.global_scaler") == "80");
CHECK(got.at("Y.normal_speed") == "39738");
CHECK(got.at("Y.center") == "500000");
CHECK(got.at("Y.margin") == "5000");
// And the axis now moves.
CHECK(m.configured());
m.sendCommand("HOME");
CHECK(m.telemetry().yaw.ready());
}
TEST_CASE("the default mock is untouched by the handshake") {
// Every existing test constructs it this way; it must behave as before.
MockMotorController m;
m.start();
CHECK(m.takeNeedLines().empty());
m.sendCommand("HOME");
CHECK(m.telemetry().yaw.ready());
}
TEST_CASE("a partial answer leaves the axis in NEEDCFG") {
// The case that matters: hardware.ini declares most of what the firmware
// wants, so most SETs land - and the axis still must not move.
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
auto need = parseNeedLine(lines[0]);
REQUIRE(need);
AxisConfigSource partial; // only the always-known anchors
partial.center_count = 500000;
partial.soft_margin = 5000;
auto plan = planFirmwareConfig(*need, partial);
CHECK_FALSE(plan.complete());
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
}
TEST_CASE("RESET is not a way out of NEEDCFG") {
// Found by reading the firmware before flashing it: Motor::reset() set
// ST_RESET unconditionally, so an axis that was never told its drivetrain
// would come back accepting HOME and MOVE on the compile-time table -
// reached by a command typed for unrelated reasons.
MockMotorController m(/*needcfg=*/true);
m.start();
m.takeNeedLines();
m.sendCommand("RESET");
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
CHECK(m.telemetry().yaw.state == AxisState::NeedConfig);
}

View File

@ -0,0 +1,223 @@
#include "doctest/doctest.h"
#include "fgc/HardwareChecks.h"
using namespace fgc;
TEST_CASE("usbfs_memory_mb is checked against the value frames actually need") {
CHECK(checkUsbfsMemory("1000", 1000).pass);
CHECK(checkUsbfsMemory("2048\n", 1000).pass);
// The reboot default. This is the exact state that was found on the
// LattePanda and stalled frame delivery.
auto low = checkUsbfsMemory("16", 1000);
CHECK_FALSE(low.pass);
CHECK(low.detail.find("kernel cmdline") != std::string::npos);
}
TEST_CASE("an unreadable usbfs node fails rather than passing by default") {
// A check that did not happen must not report PASS - that is how a silent
// precondition regression survives a green test run.
CHECK_FALSE(checkUsbfsMemory("", 1000).pass);
CHECK_FALSE(checkUsbfsMemory("not-a-number", 1000).pass);
}
TEST_CASE("USB3 LPM must be off, and anything unreadable is a failure") {
CHECK(checkUsb3Lpm("0").pass);
CHECK(checkUsb3Lpm(" 0\n").pass);
auto on = checkUsb3Lpm("1");
CHECK_FALSE(on.pass);
CHECK(on.detail.find("re-enumerate") != std::string::npos);
CHECK_FALSE(checkUsb3Lpm("").pass);
}
TEST_CASE("an SMBus-only adapter is caught before it looks like a dead sensor") {
CHECK(checkI2cFunctionality(kI2cFuncI2c).pass);
CHECK(checkI2cFunctionality(kI2cFuncI2c | 0xF0).pass);
auto smbus = checkI2cFunctionality(0xF0); // SMBus bits only
CHECK_FALSE(smbus.pass);
CHECK(smbus.detail.find("i2cdetect -F") != std::string::npos);
}
TEST_CASE("an undeclared axis has no drivetrain skew to find") {
DrivetrainExpectation want; // declared == false
DrivetrainEcho got; // all -1
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK_FALSE(r.ran);
CHECK(r.pass);
}
TEST_CASE("a firmware that cannot echo the current is a version skew, not zero current") {
DrivetrainExpectation want{true, 80, 31, 7};
DrivetrainEcho got; // all absent
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK(r.ran);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("predates") != std::string::npos);
CHECK(r.detail.find("Re-flash") != std::string::npos);
}
TEST_CASE("the IOTA tower's declared current, adopted") {
DrivetrainExpectation want{true, 80, 31, 7}; // 756 mA run, 189 mA homing at 75 mOhm
DrivetrainEcho got{80, 31, 7};
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK(r.ran);
CHECK(r.pass);
}
TEST_CASE("a homing current that did not land is named as the damaging case") {
// The exact failure that sheared the endstop: run current correct, homing
// current still at the firmware default.
DrivetrainExpectation want{true, 80, 31, 7};
DrivetrainEcho got{80, 31, 31};
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("homing IRUN wanted 7, firmware has 31") != std::string::npos);
CHECK(r.detail.find("endstop") != std::string::npos);
}
TEST_CASE("every mismatched field is reported, not just the first") {
DrivetrainExpectation want{true, 80, 31, 7};
DrivetrainEcho got{255, 16, 31};
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("GLOBALSCALER") != std::string::npos);
CHECK(r.detail.find("IRUN wanted 31") != std::string::npos);
CHECK(r.detail.find("homing IRUN") != std::string::npos);
}
// --- the generalised adoption check (config-plan A.6) -----------------------
TEST_CASE("an axis with nothing declared beyond the current is not checked") {
const DeclaredValue none[] = {{"steps_per_rev", false, 0, -1}};
auto r = checkDeclaredValues("yaw", none, 1);
CHECK_FALSE(r.ran);
CHECK(r.pass);
}
TEST_CASE("every declared value matching is a pass that says how many") {
const DeclaredValue v[] = {
{"steps_per_rev", true, 1210778, 1210778},
{"enc_deviation", true, 1500, 1500},
{"normal_speed", true, 39738, 39738},
};
auto r = checkDeclaredValues("yaw", v, 3);
CHECK(r.ran);
CHECK(r.pass);
CHECK(r.detail.find("all 3") != std::string::npos);
}
TEST_CASE("a firmware that cannot echo a declared value fails, and is named a skew") {
// Not a mismatch: the value cannot be confirmed either way, and a check that
// did not happen must never read as a pass.
const DeclaredValue v[] = {
{"steps_per_rev", true, 1210778, -1},
{"enc_deviation", true, 1500, -1},
};
auto r = checkDeclaredValues("yaw", v, 2);
CHECK(r.ran);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("does not echo 2") != std::string::npos);
CHECK(r.detail.find("Re-flash") != std::string::npos);
}
TEST_CASE("a lost SET is caught, and every wrong field is named") {
// The failure mode the handshake introduces: most SETs land, one does not,
// and the axis runs on a value nobody declared.
const DeclaredValue v[] = {
{"steps_per_rev", true, 1210778, 1210778},
{"enc_deviation", true, 1500, 177000}, // stale Delta value
{"homing_speed", true, 9958, 12000}, // firmware default
};
auto r = checkDeclaredValues("yaw", v, 3);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("enc_deviation wanted 1500, firmware has 177000") != std::string::npos);
CHECK(r.detail.find("homing_speed wanted 9958, firmware has 12000") != std::string::npos);
CHECK(r.detail.find("NEEDCFG") != std::string::npos);
}
TEST_CASE("an undeclared value is skipped even when the firmware echoes one") {
// hardware.ini not declaring it means the firmware default IS the intent.
const DeclaredValue v[] = {
{"steps_per_rev", true, 1210778, 1210778},
{"sw_mode", false, 0, 3},
};
auto r = checkDeclaredValues("yaw", v, 2);
CHECK(r.pass);
CHECK(r.detail.find("all 1") != std::string::npos);
}
TEST_CASE("a partly echoed current is a version skew, not a wrong value") {
// The bug this caught: comparing an absent field against -1 reported
// "firmware has -1", which reads as a wrong value and sends someone to
// re-send a CURRENT that was never the problem.
DrivetrainExpectation want{true, 80, 31, 7};
DrivetrainEcho got;
got.global_scaler = 80; // echoed
got.run_irun = -1; // not echoed
got.homing_irun = -1;
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("2 of 3 fields absent") != std::string::npos);
CHECK(r.detail.find("firmware has -1") == std::string::npos);
}
TEST_CASE("a real mismatch alongside an absent field still names the mismatch") {
DrivetrainExpectation want{true, 80, 31, 7};
DrivetrainEcho got;
got.global_scaler = 255; // wrong
got.run_irun = 31; // right
got.homing_irun = -1; // absent
auto r = checkDrivetrainAdopted("yaw", want, got);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("GLOBALSCALER wanted 80, firmware has 255") != std::string::npos);
}
// --- the travel must fit in positive counts -------------------------------
TEST_CASE("the Delta's centre comfortably holds its travel") {
// ~89k half-travel against a 500000 centre: 411k .. 589k, all positive.
auto r = checkTravelFitsPositive("yaw", 500000, 89000);
CHECK(r.ran);
CHECK(r.pass);
}
TEST_CASE("the IOTA's gear ratio pushes the same centre below zero") {
// The real case: half-travel 604749 against the inherited 500000 default
// gives lim_neg -104749, which is what tower 121 actually homed to.
auto r = checkTravelFitsPositive("yaw", 500000, 604749);
CHECK(r.ran);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("-104749") != std::string::npos);
CHECK(r.detail.find("yaw_center_count") != std::string::npos);
// The suggestion is the next round million above half the travel - 1000000
// here, which is the value actually adopted. Not 2000000: over-suggesting
// wastes range for no reason.
CHECK(r.detail.find("1000000") != std::string::npos);
}
TEST_CASE("raising the centre fixes it") {
auto r = checkTravelFitsPositive("yaw", 1000000, 604749);
CHECK(r.pass);
CHECK(r.detail.find("395251") != std::string::npos);
}
TEST_CASE("the suggestion scales with the drivetrain, not a fixed constant") {
// A ratio big enough that 1000000 would not be enough either.
auto r = checkTravelFitsPositive("yaw", 500000, 1400000);
CHECK_FALSE(r.pass);
CHECK(r.detail.find("2000000") != std::string::npos);
}
TEST_CASE("exactly touching zero is accepted, one count below is not") {
CHECK(checkTravelFitsPositive("yaw", 604749, 604749).pass);
CHECK_FALSE(checkTravelFitsPositive("yaw", 604748, 604749).pass);
}
TEST_CASE("an un-homed axis is not checked") {
auto r = checkTravelFitsPositive("pitch", 500000, 0);
CHECK_FALSE(r.ran);
CHECK(r.pass);
}

View File

@ -0,0 +1,204 @@
#include "doctest/doctest.h"
#include "fgc/HardwarePresence.h"
#include <algorithm>
using namespace fgc;
namespace {
// A 2-axis Delta tower with both sensors and one camera, as hardware.ini
// declares it.
HardwareConfig declaredTower() {
HardwareConfig h;
h.declared = true;
h.source_path = "/etc/fire_gimbal_control/hardware.ini";
h.axes = {"yaw", "pitch"};
h.imu = "mti-630";
h.env = "sht41";
h.cameras = {"rgb"};
return h;
}
// The same tower, working.
FoundHardware foundTower() {
FoundHardware f;
f.axes = {"yaw", "pitch"};
f.imu = true;
f.env = true;
f.cameras = {"rgb"};
f.cameras_opened = 1;
return f;
}
bool mentions(const PresenceReport& r, const std::string& subsystem) {
return std::any_of(r.findings.begin(), r.findings.end(),
[&](const PresenceFinding& f) { return f.subsystem == subsystem; });
}
} // namespace
TEST_CASE("an undeclared tower is never reconciled") {
// Every tower is in this state until it gains a hardware.ini, and gaining
// one must not be what changes its behaviour.
HardwareConfig none; // declared == false
FoundHardware bare; // nothing found at all
bare.axes = {"yaw"};
auto r = reconcilePresence(none, bare);
CHECK_FALSE(r.reconciled);
CHECK(r.findings.empty());
CHECK(presenceVerdict(r, /*strict=*/true, /*degraded=*/false).action == PresenceAction::Proceed);
}
TEST_CASE("a matching rig produces no findings") {
auto r = reconcilePresence(declaredTower(), foundTower());
CHECK(r.reconciled);
CHECK(r.findings.empty());
CHECK_FALSE(r.anyMissing());
CHECK(presenceVerdict(r, true, false).action == PresenceAction::Proceed);
}
TEST_CASE("the bug this exists to catch: a declared axis that stopped enumerating") {
// The 2-axis tower whose pitch vanished. Before reconciliation this looked
// exactly like a healthy yaw-only tower.
auto found = foundTower();
found.axes = {"yaw"};
auto r = reconcilePresence(declaredTower(), found);
REQUIRE(r.anyMissing());
CHECK(mentions(r, "axis pitch"));
CHECK(r.missing() == std::vector<std::string>{"axis pitch"});
}
TEST_CASE("an axis found but not declared is a stale file, never fatal") {
auto declared = declaredTower();
declared.axes = {"yaw"}; // the file still describes the old single-axis rig
auto r = reconcilePresence(declared, foundTower());
CHECK(r.anyUndeclared());
CHECK_FALSE(r.anyMissing());
CHECK(mentions(r, "axis pitch"));
// Strict, no --degraded: still only a warning. More hardware than declared is
// a documentation fault, not a broken tower.
auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/false);
CHECK(v.action == PresenceAction::WarnOnly);
}
TEST_CASE("an empty axes list states nothing and is not reconciled") {
// Matches hasAxis(), which answers true for everything when nothing is said.
auto declared = declaredTower();
declared.axes = {};
auto found = foundTower();
found.axes = {"yaw"};
auto r = reconcilePresence(declared, found);
CHECK_FALSE(mentions(r, "axis yaw"));
CHECK_FALSE(mentions(r, "axis pitch"));
}
TEST_CASE("no telemetry at all names the link, not every axis") {
auto found = foundTower();
found.axes = {};
found.axes_known = false;
auto r = reconcilePresence(declaredTower(), found);
REQUIRE(r.anyMissing());
CHECK(mentions(r, "motor telemetry"));
// The useful fault is the serial link; listing both axes would bury it.
CHECK_FALSE(mentions(r, "axis yaw"));
CHECK_FALSE(mentions(r, "axis pitch"));
}
TEST_CASE("sensors distinguish 'not fitted' from 'not stated'") {
SUBCASE("declared and missing is a fault") {
auto found = foundTower();
found.imu = false;
auto r = reconcilePresence(declaredTower(), found);
CHECK(mentions(r, "imu"));
CHECK(r.anyMissing());
}
SUBCASE("declared 'none' but running is a stale file") {
auto declared = declaredTower();
declared.imu = "none";
auto r = reconcilePresence(declared, foundTower());
CHECK(mentions(r, "imu"));
CHECK_FALSE(r.anyMissing());
CHECK(r.anyUndeclared());
}
SUBCASE("not stated at all is silent in both directions") {
auto declared = declaredTower();
declared.imu = "";
declared.env = "";
auto found = foundTower();
found.env = false;
auto r = reconcilePresence(declared, found);
CHECK_FALSE(mentions(r, "imu"));
CHECK_FALSE(mentions(r, "env"));
}
}
TEST_CASE("a camera that is configured but never opened is missing") {
auto found = foundTower();
found.cameras_opened = 0; // configured, declared, not on the bus
auto r = reconcilePresence(declaredTower(), found);
REQUIRE(r.anyMissing());
CHECK(mentions(r, "cameras"));
}
TEST_CASE("an unasked camera count is not a shortfall") {
auto found = foundTower();
found.cameras_opened = -1; // no camera source in this run
auto r = reconcilePresence(declaredTower(), found);
CHECK_FALSE(mentions(r, "cameras"));
}
TEST_CASE("the rollout default is warn-only, and says how to tighten it") {
auto found = foundTower();
found.axes = {"yaw"};
auto r = reconcilePresence(declaredTower(), found);
auto v = presenceVerdict(r, /*strict=*/false, /*degraded=*/false);
CHECK(v.action == PresenceAction::WarnOnly);
CHECK(v.reason.find("strict_presence") != std::string::npos);
}
TEST_CASE("strict refuses, and names both the fault and the escape") {
auto found = foundTower();
found.axes = {"yaw"};
auto r = reconcilePresence(declaredTower(), found);
auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/false);
CHECK(v.action == PresenceAction::Refuse);
CHECK(v.reason.find("axis pitch") != std::string::npos);
CHECK(v.reason.find("--degraded") != std::string::npos);
}
TEST_CASE("--degraded overrides the refusal, and is loud about it") {
auto found = foundTower();
found.axes = {"yaw"};
auto r = reconcilePresence(declaredTower(), found);
auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/true);
CHECK(v.action == PresenceAction::WarnOnly);
// It must not read like a normal startup line.
CHECK(v.reason.find("DEGRADED") != std::string::npos);
}
TEST_CASE("formatting puts the faults before the stale-file warnings") {
auto declared = declaredTower();
declared.axes = {"yaw", "pitch"};
auto found = foundTower();
found.axes = {"yaw"}; // pitch missing -> a fault
found.imu = true;
declared.imu = "none"; // imu undeclared -> stale file
auto r = reconcilePresence(declared, found);
auto lines = formatPresence(r);
REQUIRE(lines.size() == 2);
CHECK(lines[0].rfind("axis pitch:", 0) == 0);
CHECK(lines[1].rfind("imu:", 0) == 0);
}

View File

@ -23,3 +23,43 @@ TEST_CASE("configSearchPaths honours the CLI argument first") {
REQUIRE(!p.empty());
CHECK(p.front() == "/explicit/config.ini");
}
TEST_CASE("hardwareSearchPaths looks in the documented order") {
unsetenv("FGC_HARDWARE");
auto p = paths::hardwareSearchPaths("", "/opt/fgc/config.ini");
auto at = [&](const std::string& s) {
for (size_t i = 0; i < p.size(); ++i)
if (p[i] == s) return static_cast<int>(i);
return -1;
};
// /etc is the normal answer on a tower - root-owned, per-device, and OUTSIDE
// the deploy tree - so it is searched before a checkout-local file.
REQUIRE(at("/etc/fire_gimbal_control/hardware.ini") >= 0);
REQUIRE(at("/opt/fgc/hardware.ini") >= 0);
CHECK(at("/etc/fire_gimbal_control/hardware.ini") < at("/opt/fgc/hardware.ini"));
SUBCASE("an explicit argument outranks everything") {
auto q = paths::hardwareSearchPaths("/tmp/mine.ini", "/opt/fgc/config.ini");
CHECK(q.front() == "/tmp/mine.ini");
}
SUBCASE("the environment outranks /etc but not the argument") {
setenv("FGC_HARDWARE", "/tmp/env.ini", 1);
auto q = paths::hardwareSearchPaths("/tmp/cli.ini", "/opt/fgc/config.ini");
REQUIRE(q.size() >= 2);
CHECK(q[0] == "/tmp/cli.ini");
CHECK(q[1] == "/tmp/env.ini");
unsetenv("FGC_HARDWARE");
}
SUBCASE("with no config path there is simply no sibling candidate") {
auto q = paths::hardwareSearchPaths("", "");
for (const auto& s : q) CHECK(s.find("/opt/fgc/") == std::string::npos);
}
}
TEST_CASE("resolveHardwarePath returns nothing when there is no hardware.ini") {
// Not an error: every existing tower is in this state until it is migrated,
// and the program must go on reading everything from config.ini.
unsetenv("FGC_HARDWARE");
CHECK_FALSE(paths::resolveHardwarePath("/nonexistent/nope.ini", "").has_value());
}

View File

@ -82,3 +82,24 @@ TEST_CASE("parseAxisSegment reads the encoder-deviation flag 'd'") {
CHECK(e->stall == true);
CHECK(e->deviation == true);
}
TEST_CASE("the NEEDCFG state char is understood, not read as unknown") {
// The firmware refuses to move until the host answers its NEED lines, and
// says so with 'C'. A host that did not know the code would map it to
// Unknown, which downstream reads as "no telemetry arrived" - blaming the
// serial link for a configuration gap.
auto t = fgc::parseTelemetryLine("ST Y:C,0,0,00000000,0,0,0,S");
REQUIRE(t);
CHECK(t->yaw.state == fgc::AxisState::NeedConfig);
CHECK_FALSE(t->yaw.state == fgc::AxisState::Unknown);
auto lower = fgc::parseTelemetryLine("ST Y:c,0,0,00000000,0,0,0,S");
REQUIRE(lower);
CHECK(lower->yaw.state == fgc::AxisState::NeedConfig);
}
TEST_CASE("an axis in NEEDCFG is not ready") {
auto t = fgc::parseTelemetryLine("ST Y:C,0,0,00000000,0,0,0,S");
REQUIRE(t);
CHECK_FALSE(t->yaw.ready());
}

View File

@ -1,6 +1,8 @@
#include <doctest/doctest.h>
#include "fgc/TestRunner.h"
#include <algorithm>
#include "fgc/mock/MockImuSource.h"
#include "fgc/mock/MockMotorController.h"
@ -60,6 +62,45 @@ TEST_CASE("resolveTestSelection maps subsystem/test tokens") {
CHECK_FALSE(resolveTestSelection("gimbal", "bogus", m, err));
}
TEST_CASE("the hardware subsystem resolves, and is part of a bare 'test'") {
uint32_t m = 0;
std::string err;
CHECK(resolveTestSelection("hardware", "", m, err));
CHECK(m == T_HW_ALL);
CHECK(resolveTestSelection("hardware", "axes", m, err));
CHECK(m == T_HW_AXES);
CHECK(resolveTestSelection("hardware", "camera", m, err));
CHECK(m == T_HW_CAMERA);
CHECK_FALSE(resolveTestSelection("hardware", "bogus", m, err));
// `test` with no arguments must include them, or the gate is only met by
// someone who knew to ask for it.
CHECK(resolveTestSelection("", "", m, err));
CHECK((m & T_HW_ALL) == T_HW_ALL);
// They must NOT need a homed gimbal: that guard aborts the whole run, and
// `test hardware` is precisely what you run on a tower before homing it.
CHECK((T_NEEDS_MOTOR & T_HW_ALL) == 0);
}
TEST_CASE("hardware leaves report 'not checked' rather than PASS when nothing is declared") {
// The default context declares nothing, which is every un-migrated tower.
MockMotorController motor;
motor.start();
TestRunner tr(motor, nullptr, unitGeometry(), T_HW_ALL, fastProfile(), ".");
auto rep = runToCompletion(tr);
REQUIRE(rep);
for (const char* id : {"hw_axes", "hw_motor", "hw_imu", "hw_env"}) {
const auto it = std::find_if(rep->sections.begin(), rep->sections.end(),
[&](const TestSection& s) { return s.id == id; });
REQUIRE(it != rep->sections.end());
CHECK_FALSE(it->ran); // reported, but not counted as a pass
}
// An undeclared rig must not fail the run either.
CHECK(rep->all_pass);
}
TEST_CASE("TestRunner host-only run completes and produces sections") {
MockMotorController motor;
TestRunner tr(motor, nullptr, unitGeometry(), T_HOST_ALL, fastProfile(), ".");

105
tests/test_tmccurrent.cpp Normal file
View File

@ -0,0 +1,105 @@
#include "doctest/doctest.h"
#include "fgc/TmcCurrent.h"
#include <cmath>
using namespace fgc;
namespace {
// The IOTA tower's driver board.
constexpr double kR = 0.075;
} // namespace
TEST_CASE("the RMS current formula matches the datasheet") {
// Full scale: GLOBALSCALER 256 (encoded 0), IRUN 31.
CHECK(tmcRmsCurrentMa(0, 31, kR) == doctest::Approx(2419.0).epsilon(0.001));
// The fleet's setting.
CHECK(tmcRmsCurrentMa(80, 31, kR) == doctest::Approx(756.0).epsilon(0.002));
// A non-positive sense resistor is a config error, not a division to attempt.
CHECK(tmcRmsCurrentMa(80, 31, 0.0) == 0.0);
}
TEST_CASE("the fleet's run current lands on the values the firmware already ships") {
// 80 is what firmware src/config.h carries for yaw, arrived at independently.
// If this test fails, either the formula or that constant has drifted.
auto p = planTmcCurrent(kR, 756.0, 200.0);
REQUIRE(p.ok);
CHECK(p.global_scaler == 80);
CHECK(p.run_irun == 31);
CHECK(p.homing_irun == 7);
CHECK(p.run_ma == doctest::Approx(756.0).epsilon(0.002));
CHECK(p.homing_ma == doctest::Approx(189.0).epsilon(0.01));
CHECK(p.warnings.empty());
}
TEST_CASE("an achieved current is recomputed from the registers, never echoed back") {
// The log must not flatter the result: what is reported has to be what the
// chosen registers deliver.
for (double r : {0.05, 0.075, 0.11}) {
for (double run : {300.0, 756.0, 1500.0}) {
auto p = planTmcCurrent(r, run, run / 4.0);
REQUIRE(p.ok);
CHECK(p.run_ma == doctest::Approx(tmcRmsCurrentMa(p.global_scaler, p.run_irun, r)));
CHECK(p.homing_ma ==
doctest::Approx(tmcRmsCurrentMa(p.global_scaler, p.homing_irun, r)));
}
}
}
TEST_CASE("GLOBALSCALER never lands in the band the datasheet forbids") {
// 1..31 is disallowed, and 0 means 256 (full scale) - emitting either would
// mean silently maximum current on a rig whose endstop yields to full torque.
for (double r : {0.05, 0.075, 0.11, 0.22}) {
for (double run = 10.0; run <= 3000.0; run += 10.0) {
auto p = planTmcCurrent(r, run, run / 2.0);
REQUIRE(p.ok);
CHECK(p.global_scaler >= 32);
CHECK(p.global_scaler <= 255);
}
}
}
TEST_CASE("a run current the driver cannot reach is clamped and named") {
auto p = planTmcCurrent(kR, 5000.0, 200.0);
REQUIRE(p.ok);
CHECK(p.global_scaler == 255);
CHECK(p.run_ma < 2500.0);
REQUIRE(p.warnings.size() >= 1);
CHECK(p.warnings[0].find("5000") != std::string::npos);
}
TEST_CASE("a homing current above the run current is refused, not obeyed") {
auto p = planTmcCurrent(kR, 756.0, 900.0);
REQUIRE(p.ok);
CHECK(p.homing_irun == p.run_irun);
REQUIRE(p.warnings.size() >= 1);
CHECK(p.warnings[0].find("above the run current") != std::string::npos);
}
TEST_CASE("a homing current below the smallest expressible step says so") {
auto p = planTmcCurrent(kR, 756.0, 5.0);
REQUIRE(p.ok);
CHECK(p.homing_irun == 0);
CHECK(p.homing_ma > 5.0); // stronger than asked - the direction worth naming
bool named = false;
for (const auto& w : p.warnings)
if (w.find("smallest step") != std::string::npos) named = true;
CHECK(named);
}
TEST_CASE("the homing current never exceeds the run current after rounding") {
for (double r : {0.05, 0.075, 0.11}) {
for (double run = 50.0; run <= 2000.0; run += 50.0) {
auto p = planTmcCurrent(r, run, run * 0.99);
REQUIRE(p.ok);
CHECK(p.homing_irun <= p.run_irun);
}
}
}
TEST_CASE("non-positive inputs are rejected rather than computed") {
CHECK_FALSE(planTmcCurrent(0.0, 756.0, 200.0).ok);
CHECK_FALSE(planTmcCurrent(kR, 0.0, 200.0).ok);
CHECK_FALSE(planTmcCurrent(kR, 756.0, 0.0).ok);
CHECK(planTmcCurrent(0.0, 756.0, 200.0).warnings.size() == 1);
}