Compare commits

..

6 Commits

42 changed files with 2992 additions and 56 deletions

View File

@ -64,6 +64,10 @@ add_library(fgc_core STATIC
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
@ -84,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
@ -110,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)

View File

@ -78,8 +78,9 @@ baud = 115200
; (an onboard hardware UART), so this is a stable /dev/ttyS* node - NOT a USB
; device and NOT /dev/ttyUSB0-3 (those are the Quectel modem). Find it with
; ls /dev/ttyS* ; dmesg | grep -iE 'ttyS|LPSS|HSUART' (often /dev/ttyS4)
; Enable with [Features] enable_imu = true. The host reconfigures the MTi to a
; Euler + calibrated 100 Hz stream at startup.
; Enable by declaring it in hardware.ini as [Hardware] imu = <model>; the legacy
; [Features] enable_imu applies only on a tower with no hardware.ini. The host
; reconfigures the MTi to a Euler + calibrated 100 Hz stream at startup.
device =
baud = 115200
@ -145,8 +146,9 @@ z = 0
; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the
; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus
; number varies by board; confirm with `i2cdetect -y N` (SHT41 shows up at
; 0x44). Enable with [Features] enable_env = true (or run with --mock-env,
; which implies it). i2c_addr takes decimal or 0x-prefixed hex.
; 0x44). Enable by declaring it in hardware.ini as [Hardware] env = <model>; the
; legacy [Features] enable_env applies only on a tower with no hardware.ini (or
; run with --mock-env, which implies it). i2c_addr takes decimal or 0x-hex.
i2c_device = /dev/i2c-1
i2c_addr = 0x44
period_ms = 2000
@ -227,12 +229,23 @@ enable_mqtt = true
; 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
@ -243,6 +256,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

View File

@ -39,11 +39,24 @@ firmware_env = leonardo
; 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'.
; 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
@ -104,6 +117,43 @@ homing_current_ma = 200
; 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 = 2.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". Re-measure whenever the
; homing current changes: the free-running gap is coupled to it.
enc_deviation = 1500
; 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

View File

@ -49,12 +49,14 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) |
| `Features` | `enable_serial` | bool | `true` | (reserved) |
| `Features` | `enable_imu` | bool | `false` | Use the Xsens MTi orientation/IMU |
| `Features` | `enable_imu` | bool | `false` | **Legacy.** Derived from `[Hardware] imu` wherever that is declared; kept only for towers without a `hardware.ini`. A flag contradicting the declaration is warned about and ignored |
| `Features` | `mock_camera` | bool | `false` | Use the simulated camera |
| `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller |
| `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi |
| `Features` | `enable_env` | bool | `false` | Use the ambient temp/humidity sensor (SHT41) |
| `Features` | `enable_env` | bool | `false` | **Legacy.** Derived from `[Hardware] env` wherever that is declared; same rule as `enable_imu` |
| `Features` | `mock_env` | bool | `false` | Use the simulated env sensor instead of the SHT41 |
| `Features` | `mock_needcfg` | bool | `false` | Make the simulated motor controller run the config handshake (emit `NEED`, refuse to move until answered) |
| `Features` | `strict_presence` | bool | `false` | Refuse to start when `hardware.ini` declares a subsystem this rig does not have. Off during the fleet rollout; turn it on per tower once that file is known good. `--degraded` overrides it |
| `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
| `IMU` | `baud` | int | `115200` | MTi serial baud rate |
| `IMU` | `x` / `y` / `z` | float ±360 | `0` | Mounting rotation in degrees about forward/right/down, applied to every IMU reading (see below). All zero = the IMU is aligned with the gimbal |
@ -73,6 +75,11 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `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 |
@ -399,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,
@ -420,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` |
@ -625,6 +689,86 @@ host checks them **before** homing. If an axis declares a reduced homing current
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.
> **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

View File

@ -384,10 +384,10 @@ a yaw-only tower, captures one elevation band, and reports itself healthy.
| Today | After |
|---|---|
| **Production speeds hardcoded in `Application.cpp:1040` contradict `firmware/src/config.h`** — and win | one value in `hardware.ini`, requested by the firmware |
| ~~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` | derived from `imu` / `env`; the flags retire |
| ~~`[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.
@ -426,7 +426,7 @@ Phase 1 is deployable on its own. Phase 2 requires it.
## Progress
Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`).
216 tests pass, up from a 204 baseline.
295 tests pass, up from a 204 baseline.
| § | Item | State |
|----|------|-------|
@ -434,13 +434,24 @@ Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-confi
| 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** (the `hardware/<board>/` setup kit is not started) |
| 10 | Presence reconciliation (declared vs found) | **not started** — `hasAxis()` is in place and returns true for an undeclared rig, so callers can adopt it before reconciliation exists |
| 11 | `test hardware` | **not started** |
| 5 | Delete the `Application.cpp:1040` speed literals | **not started** — blocked on decision 2 |
| 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) |
| 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** | **written, NOT compile-verified.** `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. Parser verified locally (30 native tests, g++); the rest type-checks against stubs on both board branches but has never been built by PlatformIO or run on hardware |
| 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` |
Nothing yet changes behaviour on a tower without a `hardware.ini`: absent is silent, and
`hardware.declared` stays false. Verified by running the binary both ways.
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.
---
@ -448,9 +459,12 @@ Nothing yet changes behaviour on a tower without a `hardware.ini`: absent is sil
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 in `Application.cpp:1040` contradict the firmware's
tuned-down defaults and win.** Is the intended yaw production speed 30000 or 50000? Live on the fleet
now, and worth answering before the refactor (§5).
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

View File

@ -297,7 +297,23 @@ commit `aab1925`.
|---|---|---|
| 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** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass |
| 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) |
> **Not compile-verified.** PlatformIO is not installed on the dev box and the firmware is built
> *remotely on the tower* by `firmware/deploy.sh`. What was verified locally: both branches of
> `platform.h` syntax-check against stub headers — the AVR branch under `gnu++11` (what the Arduino AVR
> core uses) and the RP2040 branch under `gnu++17` (what arduino-pico uses) — and an unrecognised
> architecture fails on the `#error` rather than compiling silently.
>
> 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
@ -357,22 +373,6 @@ turns, or did the motor spin while the platform stayed put? Check any cabling
through that joint before moving the axis further.
</details>
| 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** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass |
| 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 |
| 1 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) |
> **Not compile-verified.** PlatformIO is not installed on the dev box and the firmware is built
> *remotely on the tower* by `firmware/deploy.sh`. What was verified locally: both branches of
> `platform.h` syntax-check against stub headers — the AVR branch under `gnu++11` (what the Arduino AVR
> core uses) and the RP2040 branch under `gnu++17` (what arduino-pico uses) — and an unrecognised
> architecture fails on the `#error` rather than compiling silently.
>
> 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.
---

View File

@ -15,6 +15,10 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [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/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` |
| [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 |

View File

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

View File

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

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

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

View File

@ -77,6 +77,40 @@ struct AxisDrivetrainConfig {
// 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 {
@ -174,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 {
@ -239,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
};

View File

@ -31,6 +31,17 @@ struct DumpAxis {
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)

View File

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

View File

@ -0,0 +1,91 @@
#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);
} // namespace fgc

View File

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

View File

@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <vector>
namespace fgc {
@ -70,6 +71,14 @@ public:
virtual std::string lastDiag() = 0;
virtual unsigned diagSeq() const = 0;
// NEED lines the firmware has emitted since they were last taken, one per
// axis, in arrival order (hardware-config-plan.md section 6). Taking them
// clears the queue: they are a request to answer once, not a state to poll.
//
// Default: none. Only the real controller sees the wire; the mock has no
// firmware to ask, and a null implementation would have nothing to say.
virtual std::vector<std::string> takeNeedLines() { return {}; }
// Whether the underlying link is usable.
virtual bool connected() const = 0;
};

View File

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

View File

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

View File

@ -3,9 +3,12 @@
#include "fgc/IMotorController.h"
#include "fgc/Logger.h"
#include <map>
#include <mutex>
#include <set>
#include <sstream>
#include <string>
#include <vector>
namespace fgc {
@ -16,7 +19,39 @@ namespace fgc {
// move -> settle -> trigger loop runs exactly as it would against real hardware.
class MockMotorController : public IMotorController {
public:
void start() override { LOG_INFO << "[mock] motor controller started"; }
// `needcfg`: emit NEED lines on start and refuse to move until they are
// answered, the way the real firmware will (hardware-config-plan.md
// section 6). Off by default so every existing test sees the old mock.
//
// This is what lets the HOST half of the handshake be built and verified
// before the firmware half exists - and it is not throwaway: it stays as the
// way the handshake is exercised on a dev box with no rig.
explicit MockMotorController(bool needcfg = false) : needcfg_(needcfg) {}
void start() override {
LOG_INFO << "[mock] motor controller started";
if (!needcfg_) return;
std::lock_guard<std::mutex> lock(mutex_);
static const char* kKeys[] = {"steps_per_rev", "global_scaler", "run_irun",
"homing_irun", "sw_mode", "enc_deviation",
"normal_speed", "homing_speed", "center_speed",
"center", "margin"};
need_lines_.clear();
unanswered_.clear();
for (const char* axis : {"Y", "P"}) {
std::string line = std::string("NEED ") + axis;
for (const char* k : kKeys) {
line += std::string(" ") + k;
// Track the same set the line asks for, so the axis unlocks only
// when EVERY key has been answered - a partial answer must leave
// it in NEEDCFG, which is the behaviour worth simulating.
unanswered_.insert(std::string(axis) + "." + k);
}
need_lines_.push_back(line);
}
configured_ = false;
LOG_INFO << "[mock] NEEDCFG: asking the host for config, refusing to move until answered";
}
void stop() override { LOG_INFO << "[mock] motor controller stopped"; }
void sendCommand(const std::string& cmd) override {
@ -27,6 +62,21 @@ public:
in >> verb;
for (auto& c : verb) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (verb == "SET") {
// Record what the host answered, so a test can assert the handshake
// actually put the right values on the wire.
std::string axis, key, value;
in >> axis >> key >> value;
settings_[axis + "." + key] = value;
unanswered_.erase(axis + "." + key);
if (unanswered_.empty()) configured_ = true;
return;
}
if (needcfg_ && !configured_ &&
(verb == "HOME" || verb == "MOVE" || verb == "ENABLE")) {
LOG_WARN << "[mock] ERR need config (" << verb << " rejected)";
return;
}
if (verb == "HOME") {
homed_ = true;
deviation_ = false; // homing clears the latch, as on the real driver
@ -63,10 +113,34 @@ public:
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 +205,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) {
@ -156,7 +250,7 @@ private:
} catch (const std::exception&) {}
}
std::mutex mutex_;
mutable std::mutex mutex_;
AxisTelemetry yaw_;
AxisTelemetry pitch_;
long yaw_target_ = 0;

View File

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

View File

@ -29,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")
@ -103,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>();

View File

@ -9,6 +9,9 @@
#include "fgc/HelpText.h"
#include "fgc/Paths.h"
#include "fgc/CameraProfile.h"
#include "fgc/AxisSpeed.h"
#include "fgc/FirmwareConfig.h"
#include "fgc/HardwarePresence.h"
#include "fgc/TmcCurrent.h"
#include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
@ -302,7 +305,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);
}
@ -605,7 +608,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;
}
@ -1136,12 +1139,141 @@ struct Application::Impl {
"counts_per_deg both skipped";
}
}
// Production move speeds for subsequent MOVE commands.
motor->sendCommand("SPEED Y 50000");
if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("SPEED P 150000");
// 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';
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.
@ -1256,6 +1388,65 @@ struct Application::Impl {
// 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;
// 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");
@ -1742,7 +1933,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;
}
@ -1890,7 +2081,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.
@ -1911,8 +2102,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();
}
@ -2330,6 +2535,19 @@ struct Application::Impl {
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);
@ -2371,6 +2589,7 @@ struct Application::Impl {
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
while (running && !g_shutdown_requested.load()) {
drainCommands();
answerFirmwareNeeds(); // the firmware asks; hardware.ini answers
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick();
checkTrackingError(); // warn on live encoder-error excursions

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

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

View File

@ -350,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));
@ -379,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);
{
@ -437,6 +465,14 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
// 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);
@ -520,6 +556,21 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
return cfg;
}
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;

View File

@ -165,6 +165,12 @@ DumpData parseDump(const std::string& block) {
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"));

View File

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

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

@ -0,0 +1,187 @@
#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;
}
} // namespace fgc

View File

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

View File

@ -120,6 +120,13 @@ const std::vector<HelpSection>& helpCatalog() {
"token names a config profile. Each run is compared to a pinned baseline",
"and the previous run. Esc cancels.",
"Example: test gimbal homing / test list / test baseline"}},
{"test hardware [axes|motor|imu|env|camera]",
"Check the rig against what hardware.ini declares.", {
"Preconditions, not measurements: the declared axes appear in telemetry and",
"no others; the firmware echoes the drivetrain current hardware.ini asked for;",
"a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not",
"SMBus-only) and its CRC checks out; the cameras opened and the USB",
"preconditions hold. Nothing declared reports 'not checked', never PASS."}},
{"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",

View File

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

View File

@ -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,320 @@ 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;
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

View File

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

View File

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

View File

@ -1058,7 +1058,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),
@ -1103,7 +1103,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);

View File

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

113
tests/test_axisspeed.cpp Normal file
View File

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

View File

@ -691,3 +691,47 @@ TEST_CASE("non-numeric values still compare as text") {
{{"device", "/dev/ttyUSB0"}}, "a save");
CHECK(out.find(";prev device = /dev/ttyACM0") != std::string::npos);
}
// --- [Hardware] imu/env retiring [Features] enable_imu/enable_env (plan A.12) ---
TEST_CASE("an undeclared rig still obeys the legacy flag exactly as before") {
// Every tower until it is migrated. Behaviour must not change.
auto on = resolveSensorEnable("", true, true);
CHECK(on.enabled);
CHECK(on.source == SensorEnableSource::Flag);
CHECK_FALSE(on.conflict);
auto off = resolveSensorEnable("", false, true);
CHECK_FALSE(off.enabled);
auto absent = resolveSensorEnable("", false, false);
CHECK_FALSE(absent.enabled);
CHECK(absent.source == SensorEnableSource::Default);
}
TEST_CASE("a declared sensor needs no second key agreeing with it") {
// The trap this removes: declare the IMU, forget enable_imu, and presence
// reconciliation reports missing HARDWARE for what is a missing flag.
auto e = resolveSensorEnable("mti-630", /*flag_value=*/false, /*flag_present=*/false);
CHECK(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK_FALSE(e.conflict);
}
TEST_CASE("'none' cannot be contradicted into enabling a sensor") {
auto e = resolveSensorEnable("none", /*flag_value=*/true, /*flag_present=*/true);
CHECK_FALSE(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK(e.conflict); // named, so the stale key can be deleted
}
TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported") {
auto e = resolveSensorEnable("sht41", /*flag_value=*/false, /*flag_present=*/true);
CHECK(e.enabled);
CHECK(e.conflict);
// Agreeing is not a conflict, merely redundant.
auto agree = resolveSensorEnable("sht41", true, true);
CHECK(agree.enabled);
CHECK_FALSE(agree.conflict);
}

View File

@ -165,3 +165,39 @@ TEST_CASE("a DUMP from firmware without the current fields reports -1, not zero"
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);
}

View File

@ -0,0 +1,249 @@
#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());
}

View File

@ -0,0 +1,176 @@
#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);
}

View File

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

View File

@ -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(), ".");