Compare commits
33 Commits
1a572cb074
...
943f978a73
| Author | SHA1 | Date |
|---|---|---|
|
|
943f978a73 | |
|
|
7ef2abcd4a | |
|
|
57a166c9a4 | |
|
|
c2a2459f81 | |
|
|
575e3c590b | |
|
|
e2bdc8debf | |
|
|
816242725a | |
|
|
07de495840 | |
|
|
041ad2a18f | |
|
|
6f88b00856 | |
|
|
842d95eeca | |
|
|
a554a3fd9c | |
|
|
522ba072ec | |
|
|
579c4ec166 | |
|
|
b5d1fac4cb | |
|
|
4f57651c9b | |
|
|
2191a9d8cb | |
|
|
f695a4eac6 | |
|
|
f9e209bf2a | |
|
|
31da8012d7 | |
|
|
5b3662c529 | |
|
|
0c1d0a6df8 | |
|
|
9a61167aa6 | |
|
|
ffaa41ac2f | |
|
|
3476e5adc7 | |
|
|
649d0979cf | |
|
|
9b232c3f60 | |
|
|
2c7c62ace1 | |
|
|
08a6f7c1fa | |
|
|
b97df0adf2 | |
|
|
b0127dbde6 | |
|
|
bcf54802cb | |
|
|
76e777f5af |
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "------------------------------------------------------------")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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' \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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_;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
main.cpp
20
main.cpp
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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_);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(), ".");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue