Compare commits
6 Commits
2191a9d8cb
...
842d95eeca
| Author | SHA1 | Date |
|---|---|---|
|
|
842d95eeca | |
|
|
a554a3fd9c | |
|
|
522ba072ec | |
|
|
579c4ec166 | |
|
|
b5d1fac4cb | |
|
|
4f57651c9b |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ struct RuntimeOptions {
|
|||
bool init = false; // run the endstop-finding init sequence
|
||||
bool start = false; // begin capture automatically
|
||||
bool demo = false; // demo mode (copy placeholder image instead of encoding)
|
||||
// Start anyway when hardware.ini declares something this rig does not have.
|
||||
// Field triage only: it overrides [Features] strict_presence, and the log
|
||||
// says so in those words so it cannot become the normal way to run a tower.
|
||||
bool degraded = false;
|
||||
|
||||
// Tri-state feature overrides (unset => use the config value).
|
||||
std::optional<bool> use_mqtt;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
// Axis speed as a physical quantity, and its conversion to the TMC5160's VMAX.
|
||||
//
|
||||
// VMAX is a register value in internal units, and what it MEANS depends on the
|
||||
// drivetrain: 30000 is 21.8 deg/s on the Delta fleet and 6.0 deg/s on the IOTA
|
||||
// tower, because their gear ratios differ by 3.6x. Copying the number between
|
||||
// towers therefore produces a silently different machine, which is exactly what
|
||||
// happened - both carry the same literal.
|
||||
//
|
||||
// Declaring degrees per second instead makes the intent portable and checkable:
|
||||
// it is the quantity that determines image smear and panorama period, and it can
|
||||
// be reasoned about without knowing a gear ratio.
|
||||
//
|
||||
// The conversion needs only counts_per_deg, which the host already derives per
|
||||
// tower from the homed travel. That works because the firmware keeps XACTUAL
|
||||
// (step space) and XENC (encoder space) on the SAME scale - see
|
||||
// firmware/docs/hardware-notes.md, "Encoders track 1:1 but must be aligned to
|
||||
// XACTUAL". If that ever stops being true, this conversion breaks and the step
|
||||
// scale becomes a separate calibration.
|
||||
|
||||
// The TMC5160 ramp generator: v[counts/s] = VMAX * f_CLK / 2^24.
|
||||
//
|
||||
// 12 MHz is the internal oscillator's nominal frequency. Confirmed on the rig
|
||||
// rather than taken on faith: the predicted 0.7153 counts/s per VMAX unit is
|
||||
// within 2% of the 0.73 measured across three homing runs on tower 121.
|
||||
constexpr double kTmcClockHz = 12'000'000.0;
|
||||
constexpr double kTmcVmaxDiv = 16'777'216.0; // 2^24
|
||||
// VMAX is a 23-bit field.
|
||||
constexpr long kTmcVmaxMax = 0x7FFFFF;
|
||||
|
||||
// The fleet default, in the unit that means the same thing on every tower.
|
||||
constexpr double kDefaultScanSpeedDegPerSec = 8.0;
|
||||
|
||||
// Below this, counts_per_deg is not a drivetrain - it is an uncalibrated axis
|
||||
// still carrying AxisMap's default of 1.0.
|
||||
//
|
||||
// The floor is physical, not arbitrary: the AS5047P in ABI mode emits 4000 steps
|
||||
// per revolution (COMPONENTS.md section 7), so even an axis driven with NO gear
|
||||
// reduction reads 11.1 counts/deg. Anything under that cannot have come from a
|
||||
// real encoder on this rig. Converting a speed through such a scale yields a
|
||||
// VMAX near zero - an axis that looks configured and never finishes a sweep -
|
||||
// so it is refused rather than commanded.
|
||||
constexpr double kMinPlausibleCountsPerDeg = 10.0;
|
||||
|
||||
struct AxisSpeedPlan {
|
||||
bool ok = false;
|
||||
long vmax = 0;
|
||||
double requested_deg_s = 0.0;
|
||||
// What `vmax` actually delivers. VMAX is an integer, so this differs from the
|
||||
// request by the quantisation - negligible at production speeds, but it is
|
||||
// what the startup log should report, because it is what the axis does.
|
||||
double actual_deg_s = 0.0;
|
||||
bool clamped = false;
|
||||
std::string error; // why, when !ok
|
||||
};
|
||||
|
||||
// `counts_per_deg` may be negative to flip the direction sense (see Geometry.h);
|
||||
// only its magnitude matters to a speed, so the sign is ignored rather than
|
||||
// producing a negative VMAX.
|
||||
AxisSpeedPlan planAxisSpeed(double deg_per_s, double counts_per_deg,
|
||||
double clock_hz = kTmcClockHz);
|
||||
|
||||
// The inverse, for reporting what a firmware default or an existing literal
|
||||
// actually amounts to on this drivetrain.
|
||||
double vmaxToDegPerSec(long vmax, double counts_per_deg, double clock_hz = kTmcClockHz);
|
||||
|
||||
} // namespace fgc
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
// The host half of the config handshake (hardware-config-plan.md section 6).
|
||||
//
|
||||
// Today the firmware carries a full parameter table and GUESSES when the host
|
||||
// says nothing, so the same drivetrain value exists in two places and they can
|
||||
// disagree. Instead the firmware declares what it needs and refuses to move
|
||||
// until it has it:
|
||||
//
|
||||
// firmware -> BOOT build=abc123
|
||||
// firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation ...
|
||||
// host -> SET Y steps_per_rev 1210778
|
||||
// host -> SET Y global_scaler 80
|
||||
// ...
|
||||
//
|
||||
// This module is the pure part: read a NEED line, and work out which SET lines
|
||||
// answer it. Sending them, and deciding what to do when some cannot be answered,
|
||||
// belongs to the caller.
|
||||
|
||||
// A NEED line: the firmware naming the parameters it will not run without.
|
||||
struct NeedRequest {
|
||||
char axis = '?'; // 'Y' or 'P'
|
||||
std::vector<std::string> keys;
|
||||
};
|
||||
|
||||
// Returns nullopt for anything that is not a well-formed NEED line. A NEED with
|
||||
// no keys is well-formed and means "this axis needs nothing" - which is how a
|
||||
// firmware built for fewer axes than the rig has answers for the axis it lacks.
|
||||
std::optional<NeedRequest> parseNeedLine(const std::string& line);
|
||||
|
||||
// Everything the host can answer for one axis. Each group carries its own
|
||||
// "declared" flag because a value the host does not have must be reported as
|
||||
// unanswered rather than sent as a zero - a zero here is a real setting, and a
|
||||
// silently zeroed current or margin is a damaged rig.
|
||||
struct AxisConfigSource {
|
||||
// From [Motor.<axis>] milliamps, via TmcCurrent.h.
|
||||
bool have_current = false;
|
||||
int global_scaler = 0;
|
||||
int run_irun = 0;
|
||||
int homing_irun = 0;
|
||||
|
||||
// From [Motor] calibration and margins. Always known - they have defaults
|
||||
// that predate hardware.ini and are already sent as CENTER / MARGIN.
|
||||
long center_count = 0;
|
||||
long soft_margin = 0;
|
||||
|
||||
// From [Motor.<axis>], the mechanism's own facts.
|
||||
bool have_travel = false;
|
||||
long steps_per_rev = 0; // expected full mechanical travel, in counts
|
||||
bool have_deviation = false;
|
||||
long enc_deviation = 0;
|
||||
|
||||
// Speeds, already converted from deg/s to VMAX by the caller (AxisSpeed.h).
|
||||
// 0 means the conversion was refused - an uncalibrated scale - and the key
|
||||
// goes unanswered rather than stopping the axis with a VMAX of zero.
|
||||
long normal_vmax = 0;
|
||||
long homing_vmax = 0;
|
||||
long center_vmax = 0;
|
||||
|
||||
// Wiring facts: whether this axis has physical endstop switches on the TMC's
|
||||
// REFL/REFR, and whether its encoder is aligned and usable.
|
||||
bool have_wiring = false;
|
||||
unsigned sw_mode = 0;
|
||||
bool has_encoder = true;
|
||||
};
|
||||
|
||||
struct FirmwareConfigPlan {
|
||||
// Ready-to-send command lines, in the order the firmware asked for them.
|
||||
std::vector<std::string> sets;
|
||||
// Keys the host was asked for and has no value for. The firmware stays in
|
||||
// NEEDCFG until they are answered, so this is a startup failure, not a
|
||||
// warning to scroll past.
|
||||
std::vector<std::string> unanswered;
|
||||
// Keys that are not part of the vocabulary at all: a firmware newer than
|
||||
// this host. Kept separate from `unanswered` because the fix is different -
|
||||
// upgrade the host, rather than fill in hardware.ini.
|
||||
std::vector<std::string> unknown;
|
||||
|
||||
bool complete() const { return unanswered.empty() && unknown.empty(); }
|
||||
};
|
||||
|
||||
// Map one NEED request onto SET lines. `center` / `margin` are accepted as
|
||||
// aliases for `center_count` / `soft_margin`, because those are the names the
|
||||
// existing CENTER and MARGIN commands use and hand-typed sessions rely on them.
|
||||
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src);
|
||||
|
||||
} // namespace fgc
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#pragma once
|
||||
|
||||
#include "fgc/Config.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
// Reconciling what hardware.ini DECLARES against what the rig actually turned
|
||||
// out to have.
|
||||
//
|
||||
// The program degrades gracefully everywhere - TestRunner skips the pitch and
|
||||
// IMU leaves, CamEvent falls back to encoder pitch, CaptureScheduler sends the
|
||||
// single-axis MOVE form. That is right for a rig that genuinely lacks them, and
|
||||
// indistinguishable from a FAILURE on a rig that does not: a 2-axis tower whose
|
||||
// pitch stops enumerating becomes a yaw-only tower, captures one elevation band,
|
||||
// and reports itself healthy. Declaring the assembly is what lets startup tell
|
||||
// those two apart.
|
||||
|
||||
// What this run actually found, from the sources that can answer: telemetry for
|
||||
// the axes, the constructed sensor objects for the IMU and env sensor, camera
|
||||
// enumeration for the cameras.
|
||||
struct FoundHardware {
|
||||
std::vector<std::string> axes; // yaw implied by any ST line, pitch by pitch_present
|
||||
bool imu = false;
|
||||
bool env = false;
|
||||
std::vector<std::string> cameras; // by [Camera.<name>] block name, as configured
|
||||
|
||||
// How many of those the source actually opened. A camera can be configured
|
||||
// and declared and still not be on the bus, which is the whole failure this
|
||||
// section exists to catch - but the source reports a count, not which ones
|
||||
// are missing, so the shortfall is named as a number. -1 = not asked.
|
||||
int cameras_opened = -1;
|
||||
|
||||
// Telemetry never arrived, so the axis list is unknown rather than empty.
|
||||
// Without this an unplugged MCU would report every declared axis as missing,
|
||||
// which is true but useless - the one fault worth naming is the serial link.
|
||||
bool axes_known = true;
|
||||
};
|
||||
|
||||
enum class PresenceIssue {
|
||||
MissingDeclared, // declared, not found - the failure this exists to catch
|
||||
UndeclaredFound, // found, not declared - the file is stale
|
||||
};
|
||||
|
||||
struct PresenceFinding {
|
||||
PresenceIssue issue;
|
||||
std::string subsystem; // "axis pitch", "imu", "env", "camera rgb"
|
||||
std::string detail; // one operator-readable sentence, naming the fix
|
||||
};
|
||||
|
||||
struct PresenceReport {
|
||||
std::vector<PresenceFinding> findings;
|
||||
|
||||
// False when no hardware.ini was read, or it declared nothing comparable.
|
||||
// Every tower is in this state until it is migrated, and it is what keeps
|
||||
// this change invisible during the rollout.
|
||||
bool reconciled = false;
|
||||
|
||||
bool anyMissing() const;
|
||||
bool anyUndeclared() const;
|
||||
std::vector<std::string> missing() const; // subsystem names, for the refusal line
|
||||
};
|
||||
|
||||
// Pure comparison; says nothing about what to do about it.
|
||||
PresenceReport reconcilePresence(const HardwareConfig& declared, const FoundHardware& found);
|
||||
|
||||
enum class PresenceAction {
|
||||
Proceed, // nothing to reconcile, or everything matches
|
||||
WarnOnly, // mismatches, but policy (or --degraded) says carry on
|
||||
Refuse, // declared hardware is missing and this tower is strict
|
||||
};
|
||||
|
||||
struct PresenceVerdict {
|
||||
PresenceAction action = PresenceAction::Proceed;
|
||||
std::string reason; // the line to log; empty when there is nothing to say
|
||||
};
|
||||
|
||||
// `strict` is [Features] strict_presence, `degraded` is --degraded.
|
||||
//
|
||||
// Warn-only is the default deliberately: through the fleet rollout no tower may
|
||||
// change behaviour on upgrade just by gaining a hardware.ini. A tower flips to
|
||||
// strict once its file is known good, so the fleet-wide flip is a config change
|
||||
// rather than a release.
|
||||
PresenceVerdict presenceVerdict(const PresenceReport& report, bool strict, bool degraded);
|
||||
|
||||
// Every finding as one log-ready line, most serious first. Empty when there are
|
||||
// none.
|
||||
std::vector<std::string> formatPresence(const PresenceReport& report);
|
||||
|
||||
} // namespace fgc
|
||||
|
|
@ -1,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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ public:
|
|||
std::string lastDump() override;
|
||||
std::string lastDiag() override;
|
||||
unsigned diagSeq() const override;
|
||||
std::vector<std::string> takeNeedLines() override;
|
||||
bool connected() const override;
|
||||
|
||||
private:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ namespace fgc {
|
|||
|
||||
class IMotorController;
|
||||
class IImuSource;
|
||||
class IEnvSensor;
|
||||
class ICameraSource;
|
||||
struct HardwareConfig;
|
||||
struct DrivetrainConfig;
|
||||
|
||||
// Leaf-test selection bitmask. Subsystem masks are unions of their leaves.
|
||||
enum TestLeaf : uint32_t {
|
||||
|
|
@ -30,14 +34,27 @@ enum TestLeaf : uint32_t {
|
|||
T_HOST_DISK = 1u << 9,
|
||||
T_HOST_MEMORY = 1u << 10,
|
||||
T_HOST_LOAD = 1u << 11,
|
||||
// `test hardware`: is this rig the one hardware.ini says it is? These are
|
||||
// preconditions rather than measurements - each either holds or does not, so
|
||||
// they carry pass/fail metrics rather than values to trend.
|
||||
T_HW_AXES = 1u << 12,
|
||||
T_HW_MOTOR = 1u << 13,
|
||||
T_HW_IMU = 1u << 14,
|
||||
T_HW_ENV = 1u << 15,
|
||||
T_HW_CAMERA = 1u << 16,
|
||||
};
|
||||
constexpr uint32_t T_GIMBAL_ALL = T_GIMBAL_HOMING | T_GIMBAL_ENCODER | T_GIMBAL_FRICTION |
|
||||
T_GIMBAL_BACKLASH | T_GIMBAL_BALANCE;
|
||||
constexpr uint32_t T_IMU_ALL = T_IMU_CONFIG | T_IMU_HEALTH | T_IMU_DRIFT;
|
||||
constexpr uint32_t T_HOST_ALL = T_HOST_THERMAL | T_HOST_DISK | T_HOST_MEMORY | T_HOST_LOAD;
|
||||
constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL;
|
||||
constexpr uint32_t T_HW_ALL = T_HW_AXES | T_HW_MOTOR | T_HW_IMU | T_HW_ENV | T_HW_CAMERA;
|
||||
constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL | T_HW_ALL;
|
||||
|
||||
// Which leaf masks need real motor / IMU hardware (used by the caller to guard).
|
||||
// Needs a HOMED gimbal, not merely a motor link: the caller refuses the whole
|
||||
// run when it is not ready. The `test hardware` leaves are deliberately NOT
|
||||
// here - they only read telemetry and DUMP, and they are the checks you want
|
||||
// on a tower that has not been homed yet.
|
||||
constexpr uint32_t T_NEEDS_MOTOR = T_GIMBAL_ALL;
|
||||
constexpr uint32_t T_NEEDS_IMU = T_IMU_ALL;
|
||||
|
||||
|
|
@ -47,6 +64,30 @@ constexpr uint32_t T_NEEDS_IMU = T_IMU_ALL;
|
|||
bool resolveTestSelection(const std::string& subsystem, const std::string& test,
|
||||
uint32_t& mask, std::string& err);
|
||||
|
||||
// Everything the `test hardware` leaves need beyond the motor and IMU the runner
|
||||
// already holds. Passed as one struct because the alternative is five more
|
||||
// constructor parameters, and because every field is optional: a leaf whose
|
||||
// input is absent reports "not run" rather than failing.
|
||||
struct HardwareTestContext {
|
||||
const HardwareConfig* declared = nullptr; // [Hardware], or null when undeclared
|
||||
const DrivetrainConfig* drive = nullptr; // [Motor.<axis>], for the current echo
|
||||
IEnvSensor* env = nullptr;
|
||||
ICameraSource* camera = nullptr;
|
||||
|
||||
std::vector<std::string> configured_cameras; // [Camera.<name>] blocks in open order
|
||||
std::string i2c_device; // [Env] i2c_device, for the adapter-class check
|
||||
// The degree maps, so a declared deg/s can be turned back into the VMAX the
|
||||
// firmware should be holding.
|
||||
AxisMap geo_yaw;
|
||||
AxisMap geo_pitch;
|
||||
double r_sense_ohm = 0.0; // to recompute what CURRENT should have sent
|
||||
|
||||
// sysfs nodes, overridable so the checks are exercisable off a tower.
|
||||
std::string usbfs_path = "/sys/module/usbcore/parameters/usbfs_memory_mb";
|
||||
std::string lpm_path; // empty => discovered from the camera's USB port, or skipped
|
||||
int usbfs_min_mb = 1000;
|
||||
};
|
||||
|
||||
// Live progress for the activity strip (mirrors CalibProgress).
|
||||
struct TestProgress {
|
||||
bool running = false;
|
||||
|
|
@ -64,7 +105,8 @@ struct TestProgress {
|
|||
class TestRunner {
|
||||
public:
|
||||
TestRunner(IMotorController& motor, IImuSource* imu, Geometry geo,
|
||||
uint32_t selection, TestProfile profile, std::string disk_path);
|
||||
uint32_t selection, TestProfile profile, std::string disk_path,
|
||||
HardwareTestContext hw = {});
|
||||
~TestRunner();
|
||||
|
||||
bool start();
|
||||
|
|
@ -89,6 +131,11 @@ private:
|
|||
void testImuHealth(TestReport& r);
|
||||
void testImuDrift(TestReport& r);
|
||||
void testHost(TestReport& r);
|
||||
void testHwAxes(TestReport& r);
|
||||
void testHwMotor(TestReport& r);
|
||||
void testHwImu(TestReport& r);
|
||||
void testHwEnv(TestReport& r);
|
||||
void testHwCamera(TestReport& r);
|
||||
|
||||
IMotorController& motor_;
|
||||
IImuSource* imu_;
|
||||
|
|
@ -96,6 +143,7 @@ private:
|
|||
uint32_t selection_;
|
||||
TestProfile profile_;
|
||||
std::string disk_path_;
|
||||
HardwareTestContext hw_;
|
||||
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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_;
|
||||
|
|
|
|||
4
main.cpp
4
main.cpp
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
#include "fgc/FirmwareConfig.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
namespace fgc {
|
||||
namespace {
|
||||
|
||||
// One answerable key: where its value comes from, and whether the host has it.
|
||||
struct Answer {
|
||||
bool have = false;
|
||||
long long value = 0;
|
||||
};
|
||||
|
||||
Answer answerFor(const std::string& key, const AxisConfigSource& s) {
|
||||
// Motor current. All three come from the same conversion, so they are
|
||||
// available together or not at all.
|
||||
if (key == "global_scaler") return {s.have_current, s.global_scaler};
|
||||
if (key == "run_irun") return {s.have_current, s.run_irun};
|
||||
if (key == "homing_irun") return {s.have_current, s.homing_irun};
|
||||
|
||||
// Position anchors. `center` / `margin` are the names the existing CENTER
|
||||
// and MARGIN commands use.
|
||||
if (key == "center_count" || key == "center") return {true, s.center_count};
|
||||
if (key == "soft_margin" || key == "margin") return {true, s.soft_margin};
|
||||
|
||||
if (key == "steps_per_rev") return {s.have_travel, s.steps_per_rev};
|
||||
if (key == "enc_deviation") return {s.have_deviation, s.enc_deviation};
|
||||
|
||||
// A VMAX of 0 is not a slow axis, it is a stopped one, so an unconverted
|
||||
// speed is reported as missing rather than sent.
|
||||
if (key == "normal_speed") return {s.normal_vmax > 0, s.normal_vmax};
|
||||
if (key == "homing_speed") return {s.homing_vmax > 0, s.homing_vmax};
|
||||
if (key == "center_speed") return {s.center_vmax > 0, s.center_vmax};
|
||||
|
||||
if (key == "sw_mode") return {s.have_wiring, static_cast<long long>(s.sw_mode)};
|
||||
if (key == "has_encoder") return {s.have_wiring, s.has_encoder ? 1 : 0};
|
||||
|
||||
return {}; // not in the vocabulary
|
||||
}
|
||||
|
||||
bool known(const std::string& key) {
|
||||
static const char* kKeys[] = {
|
||||
"global_scaler", "run_irun", "homing_irun", "center_count", "center",
|
||||
"soft_margin", "margin", "steps_per_rev", "enc_deviation",
|
||||
"normal_speed", "homing_speed", "center_speed", "sw_mode", "has_encoder",
|
||||
};
|
||||
for (const char* k : kKeys)
|
||||
if (key == k) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<NeedRequest> parseNeedLine(const std::string& line) {
|
||||
std::istringstream is(line);
|
||||
std::string tok;
|
||||
if (!(is >> tok) || tok != "NEED") return std::nullopt;
|
||||
|
||||
std::string axis;
|
||||
if (!(is >> axis)) return std::nullopt;
|
||||
if (axis.size() != 1) return std::nullopt;
|
||||
const char a = static_cast<char>(std::toupper(static_cast<unsigned char>(axis[0])));
|
||||
if (a != 'Y' && a != 'P') return std::nullopt;
|
||||
|
||||
NeedRequest r;
|
||||
r.axis = a;
|
||||
while (is >> tok) r.keys.push_back(tok);
|
||||
return r;
|
||||
}
|
||||
|
||||
FirmwareConfigPlan planFirmwareConfig(const NeedRequest& need, const AxisConfigSource& src) {
|
||||
FirmwareConfigPlan plan;
|
||||
const std::string axis(1, need.axis);
|
||||
|
||||
for (const auto& key : need.keys) {
|
||||
if (!known(key)) {
|
||||
// The firmware wants something this host has never heard of, which
|
||||
// means it is newer. Naming it is the whole diagnostic.
|
||||
plan.unknown.push_back(key);
|
||||
continue;
|
||||
}
|
||||
const Answer a = answerFor(key, src);
|
||||
if (!a.have) {
|
||||
plan.unanswered.push_back(key);
|
||||
continue;
|
||||
}
|
||||
plan.sets.push_back("SET " + axis + " " + key + " " + std::to_string(a.value));
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
} // namespace fgc
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ bool I2cBus::open() {
|
|||
return true;
|
||||
}
|
||||
|
||||
bool I2cBus::functionality(unsigned long& funcs) const {
|
||||
if (fd_ < 0) return false;
|
||||
return ::ioctl(fd_, I2C_FUNCS, &funcs) >= 0;
|
||||
}
|
||||
|
||||
void I2cBus::close() {
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ struct SerialMotorController::Impl {
|
|||
std::string latest_diag;
|
||||
std::atomic<unsigned> diag_seq{0};
|
||||
|
||||
// NEED capture: single lines, queued for the control thread to answer once.
|
||||
std::vector<std::string> need_lines;
|
||||
|
||||
// Write one command line (newline-terminated) to the controller. Used by
|
||||
// sendCommand and by the internal DUMP re-request.
|
||||
//
|
||||
|
|
@ -137,13 +140,24 @@ struct SerialMotorController::Impl {
|
|||
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
||||
LOG_WARN << "Motor controller: " << line;
|
||||
} else if (!line.empty()) {
|
||||
// OK acks and other async output (DG/DUMP/BOOT/...).
|
||||
// OK acks and other async output (DG/DUMP/BOOT/NEED/...).
|
||||
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
|
||||
captureDump(line);
|
||||
captureDiag(line);
|
||||
captureNeed(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue a NEED line for the control thread to answer. Unlike DUMP and DIAG
|
||||
// this is not a block to assemble - each line stands alone and is actionable
|
||||
// immediately, so it is queued rather than accumulated, and taking it clears
|
||||
// it. A firmware that re-asks (after a RESET, say) simply queues again.
|
||||
void captureNeed(const std::string& line) {
|
||||
if (line.rfind("NEED ", 0) != 0) return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
need_lines.push_back(line);
|
||||
}
|
||||
|
||||
// Assemble the DIAG stream. DG lines arrive interleaved with ST over many
|
||||
// seconds, so we accumulate only "DG " lines and log each live; on "DG DONE"
|
||||
// we publish the block and bump diag_seq so the app can pick up the result.
|
||||
|
|
@ -281,6 +295,13 @@ std::string SerialMotorController::lastDiag() {
|
|||
|
||||
unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); }
|
||||
|
||||
std::vector<std::string> SerialMotorController::takeNeedLines() {
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
std::vector<std::string> out;
|
||||
out.swap(impl_->need_lines);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool SerialMotorController::connected() const { return impl_->connected; }
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(), ".");
|
||||
|
|
|
|||
Loading…
Reference in New Issue