mirror of
https://github.com/PX4/PX4-Autopilot.git
synced 2026-08-17 22:29:21 +08:00
refactor(bench): restructure suite into px4bench package with bench/ and sih/ split
Turn the flat script pile into a standalone project so contributors can navigate and extend it: - px4bench/ shared library package: core primitives in __init__ (Reporter, connect, MavlinkShell, reboot/replug, viewer tee, mavlink status parsers, pymavlink add_message workaround) plus protocol modules params.py, missions.py, and ftp.py extracted from the tests. Zero helper duplication remains across scripts. - bench/ holds the real-firmware tests (boot_health, reboot_loop, usb_replug, link_forwarding, param_stress, mission_stress, log_transfer); sih/ holds the simulated flight (flight_mission), making the simulation/no-simulation boundary explicit. - pyproject.toml (px4bench 0.1.0, BSD-3-Clause, pymavlink/pyserial deps, pyulog extra) so pip install -e Tools/bench_test works; every script remains directly runnable without installation via a parent-dir path shim. - README rewritten contributor-first: architecture, per-test justification tied to the v1.18 risk areas, why pymavlink over MAVSDK, how to add a test, baseline workflow. - Consistent CLI surface (shared connection args; --report-dir replaces log_transfer's --outdir); decorative section banners removed. All hardware-learned behavior is preserved exactly: param echo drain and match-by-value, shell sentinel strip-all, the add_message workaround, explicit param save before reboot, mission clear before upload, RTL in MAV_FRAME_MISSION, and the post-flight ULog download. Signed-off-by: Ramon Roche <mrpollo@gmail.com>
This commit is contained in:
+215
-199
@@ -1,252 +1,269 @@
|
||||
# PX4 bench-test suite
|
||||
# px4bench: PX4 bench-test suite
|
||||
|
||||
Semi-automated release qualification for PX4 on real NuttX hardware
|
||||
(Pixhawk-class boards on a bench, connected over USB and optionally a
|
||||
telemetry radio).
|
||||
|
||||
PX4 v1.18 merged a large TSAN/concurrency series (PR #27606 plus follow-ups
|
||||
#27809 and #27813) that reworked mavlink locking, uORB callbacks, WorkQueue
|
||||
lifetime, parameters, and dataman. CI builds NuttX firmware but never boots it.
|
||||
The characteristic failure mode of this class of bug on NuttX is a silent hang
|
||||
(for example a mutex that was previously zero-initialized and locked from the
|
||||
wrong context), not a crash. This suite makes hangs visible on real
|
||||
Pixhawk-class hardware on a bench: every operation has a timeout, and a timeout
|
||||
is reported as FAIL naming exactly what stalled.
|
||||
lifetime, parameters, and dataman. CI builds NuttX firmware but never boots
|
||||
it. The characteristic failure mode of this class of bug on NuttX is a
|
||||
silent hang (for example a mutex that was previously zero-initialized and
|
||||
locked from the wrong context), not a crash. This suite makes hangs visible:
|
||||
every operation has a timeout, and a timeout is reported as FAIL naming
|
||||
exactly what stalled. A hang is the finding, not a nuisance.
|
||||
|
||||
The suite never arms the vehicle. Logging is triggered with `logger on` /
|
||||
`logger off`. A hang is the finding, not a nuisance: when a check times out the
|
||||
tool prints which operation stalled and exits nonzero rather than blocking.
|
||||
The bench tests never arm the vehicle; logging is triggered with
|
||||
`logger on` / `logger off`. The one test that flies does so in simulation on
|
||||
the FMU (SIH, `SYS_HITL=2`) with `pwm_out_sim` in place of real outputs, and
|
||||
it lives in its own directory so there is no ambiguity about which is which.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Tools/bench_test/
|
||||
README.md
|
||||
pyproject.toml # pip install -e Tools/bench_test (optional)
|
||||
run_bench_suite.py # orchestrator for the non-interactive bench tests
|
||||
px4bench/ # shared library package
|
||||
__init__.py # Reporter, connect, MavlinkShell, reboot/replug,
|
||||
# viewer tee, mavlink-status parsers,
|
||||
# pymavlink add_message workaround
|
||||
params.py # param read/set/drain/echo, int32 union encoding
|
||||
missions.py # mission items, upload/download/compare/clear
|
||||
ftp.py # MAVFTP list/download, ULog magic, log root
|
||||
bench/ # real-firmware bench tests, no simulation
|
||||
boot_health.py
|
||||
reboot_loop.py
|
||||
usb_replug.py
|
||||
link_forwarding.py
|
||||
param_stress.py
|
||||
mission_stress.py
|
||||
log_transfer.py
|
||||
sih/ # simulation-in-hardware (SYS_HITL=2) tests
|
||||
flight_mission.py
|
||||
```
|
||||
|
||||
Every script is directly executable from any working directory; no install
|
||||
is required (each carries a path shim to find `px4bench/`). Installing the
|
||||
library is optional and only needed if you want `import px4bench` from your
|
||||
own scripts:
|
||||
|
||||
```
|
||||
pip install -e Tools/bench_test # library only
|
||||
pip install -e "Tools/bench_test[ulog]" # + pyulog for deep ULog verification
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.
|
||||
- Required: `pymavlink`, `pyserial`.
|
||||
- Optional: `pyulog` (better .ulg verification; magic-byte check is used as
|
||||
fallback when absent).
|
||||
- Python 3.8+.
|
||||
- Required: `pymavlink`, `pyserial` (`pip3 install --user pymavlink pyserial`).
|
||||
- Optional: `pyulog` (deep .ulg verification; a magic-byte check is the
|
||||
fallback).
|
||||
|
||||
```
|
||||
pip3 install --user pymavlink pyserial
|
||||
pip3 install --user pyulog # optional
|
||||
```
|
||||
Finding the board on macOS: `ls /dev/tty.usbmodem*`. Close QGroundControl
|
||||
first; it holds the serial port.
|
||||
|
||||
Finding the board on macOS:
|
||||
|
||||
```
|
||||
ls /dev/tty.usbmodem*
|
||||
```
|
||||
|
||||
Close QGroundControl before running anything. QGC holds the serial port and the
|
||||
tools will not be able to open it.
|
||||
|
||||
Every script prints `[PASS]` / `[FAIL]` per check and exits nonzero on any
|
||||
failure.
|
||||
`CONNECTION` is a serial device (`/dev/tty.usbmodem*`) or `udp:IP:PORT` /
|
||||
`tcp:IP:PORT`. Default baud is 57600. Every script prints `[PASS]` /
|
||||
`[FAIL]` per check and exits nonzero on any failure.
|
||||
|
||||
## Quick start
|
||||
|
||||
Full suite over a single USB link:
|
||||
|
||||
```
|
||||
./run_bench_suite.py /dev/tty.usbmodem*
|
||||
# full non-interactive bench suite, single USB link
|
||||
./run_bench_suite.py /dev/tty.usbmodem01
|
||||
|
||||
# with a telemetry radio as a second link (enables link_forwarding and
|
||||
# alternating mission uploads)
|
||||
./run_bench_suite.py /dev/tty.usbmodem01 /dev/tty.usbserial-RADIO
|
||||
|
||||
# operator-assisted USB re-enumeration test (not part of the suite)
|
||||
./bench/usb_replug.py /dev/tty.usbmodem01
|
||||
|
||||
# simulated flight on the FMU (reconfigures the board, run separately)
|
||||
./sih/flight_mission.py /dev/tty.usbmodem01
|
||||
```
|
||||
|
||||
Full suite with a second link (telemetry radio); enables the dual-link tests:
|
||||
## Why pymavlink
|
||||
|
||||
```
|
||||
./run_bench_suite.py /dev/tty.usbmodem* /dev/tty.usbserial-RADIO
|
||||
```
|
||||
|
||||
The operator-assisted USB re-enumeration test is not part of the automated
|
||||
suite. Run it manually:
|
||||
|
||||
```
|
||||
./usb_replug.py /dev/tty.usbmodem*
|
||||
```
|
||||
The bench tests exercise the raw MAVLink protocol surface on purpose: param
|
||||
echo semantics (duplicate PARAM_VALUE broadcasts are part of what we
|
||||
verify), mission handshake retransmits and per-seq item requests, MAVFTP
|
||||
burst-read internals with stall detection by byte count, and the nsh shell
|
||||
over SERIAL_CONTROL. MAVSDK abstracts exactly those layers away, and adds an
|
||||
asyncio runtime plus the mavsdk_server gRPC binary as dependencies. The one
|
||||
plausible candidate was flight orchestration in `sih/flight_mission.py`, but
|
||||
that test also depends on the nsh shell (arming via `commander`, explicit
|
||||
`param save`, enabling viewer streams) and on teeing raw frames to a viewer,
|
||||
neither of which MAVSDK exposes. Verdict: pymavlink everywhere.
|
||||
|
||||
## Risk map
|
||||
|
||||
| Test | Subsystem exercised | What a failure looks like |
|
||||
Repetition is the test: single-shot operations do not hit races, which is
|
||||
why the stress tests loop.
|
||||
|
||||
| Test | Subsystem exercised | Why it exists / what failure looks like |
|
||||
| --- | --- | --- |
|
||||
| boot_health | WorkQueue lifetime + uORB callback rework | Task in ERROR state, a required work queue missing, or a topic not publishing |
|
||||
| usb_replug | mavlink instance lifecycle across USB re-enumeration | Heartbeat does not return, mavlink instance count changes, or free RAM grows across cycles |
|
||||
| dual_link_forwarding | reworked mavlink nested-send lock path (silently deadlockable on NuttX) | Silent traffic gap > 5s on either link, param download stalls, or a link is dead afterward |
|
||||
| param_torture | parameter subsystem locking | A set/readback mismatch, or the param does not persist across reboot |
|
||||
| mission_torture | dataman + new mission shared-state mutex | Upload/download mismatch, or an operation stalls |
|
||||
| mavftp_log | logger + MAVFTP path | Log does not start/stop, download stalls, or the .ulg fails integrity check |
|
||||
| reboot_loop | boot-time initialization ordering (locked zero-init mutex bugs bite at boot) | Board does not reconnect within the cycle timeout |
|
||||
| bench/boot_health | WorkQueue lifetime + uORB callback rework | Task in ERROR state, required work queue missing, topic not publishing; baseline mode catches rate drift across firmware versions |
|
||||
| bench/reboot_loop | boot-time initialization ordering | Locked zero-init mutex bugs bite at boot; a board that boots once but not every time. No reconnect within timeout = FAIL with elapsed time |
|
||||
| bench/usb_replug | mavlink instance lifecycle across USB re-enumeration | Instance count climbing or free RAM growing per replug cycle = leak in link teardown |
|
||||
| bench/link_forwarding | reworked mavlink nested-send lock path (silently deadlockable on NuttX) | Silent traffic gap > 5s on either link, a stalled param download, or a dead link after heavy simultaneous two-link use |
|
||||
| bench/param_stress | parameter storage concurrency from the 2026-07-02 TSAN batch (DynamicSparseLayer races, AtomicTransaction) | Echo/readback mismatch under repetition, a download that stalls mid-list, or a save that does not survive reboot |
|
||||
| bench/mission_stress | new mission shared-state mutex (#27813) + dataman TSAN fixes | Upload/download round-trip corruption or a handshake that stalls; with two links the mutex is hit from two channels |
|
||||
| bench/log_transfer | logger + MAVFTP (FILE_TRANSFER_PROTOCOL replies are nested sends) | A burst-read download that stops mid-file, or a ULog that fails integrity checks |
|
||||
| sih/flight_mission | commander/navigator/land-detector flight logic on real NuttX scheduling | Arming, takeoff, waypoint progression, RTL, land detection or auto-disarm failing per-phase timeouts |
|
||||
|
||||
`CONNECTION` is a serial device (`/dev/tty.usbmodem*`) or `udp:IP:PORT` /
|
||||
`tcp:IP:PORT`. Default baud is 57600.
|
||||
|
||||
## Tests
|
||||
## Bench tests (real firmware, no simulation)
|
||||
|
||||
### boot_health
|
||||
|
||||
Purpose: capture a snapshot of the running system after the WorkQueue and uORB
|
||||
callback rework and check it is healthy.
|
||||
Snapshot of a running board: `top once`, `work_queue status`, `perf`,
|
||||
`uorb top -1`, `mavlink status`, `free`, `ver all` over the MAVLink shell,
|
||||
saved into a timestamped report dir.
|
||||
|
||||
```
|
||||
./boot_health.py CONNECTION [-b BAUD] [--report-dir DIR] [--require-wq LIST]
|
||||
./bench/boot_health.py CONNECTION [-b BAUD] [--report-dir DIR] [--require-wq LIST]
|
||||
./bench/boot_health.py --baseline OLDDIR NEWDIR [--tolerance PCT]
|
||||
```
|
||||
|
||||
Captures `top once`, `work_queue status`, `perf`, `uorb top -1`,
|
||||
`mavlink status`, `free`, and `ver all` over the MAVLink shell into a
|
||||
timestamped report dir. PASS means no task is in ERROR state, the required work
|
||||
queues are present (`wq:rate_ctrl`, `wq:hp_default`, `wq:lp_default`), and
|
||||
topics are publishing. A FAIL names the failing check; if the shell itself
|
||||
stalls, that stall is the finding. Attach the report dir when reporting.
|
||||
|
||||
Baseline diff of two report dirs (uorb rates within tolerance, work queue sets
|
||||
equal):
|
||||
|
||||
```
|
||||
./boot_health.py --baseline OLDDIR NEWDIR [--tolerance PCT]
|
||||
```
|
||||
|
||||
### usb_replug (operator-assisted)
|
||||
|
||||
Purpose: exercise mavlink instance lifecycle across USB re-enumeration.
|
||||
|
||||
```
|
||||
./usb_replug.py CONNECTION [-b BAUD] [--cycles M] [--heartbeat-timeout S] [--ram-tolerance BYTES]
|
||||
```
|
||||
|
||||
Guides you through unplug/replug cycles (default 5). PASS means the heartbeat
|
||||
returns after each replug, the mavlink instance count stays constant, and free
|
||||
RAM does not grow. A FAIL names the cycle and the failing condition; a heartbeat
|
||||
that never returns within `--heartbeat-timeout` is the finding.
|
||||
|
||||
### dual_link_forwarding (flagship, needs 2 links)
|
||||
|
||||
Purpose: exercise the reworked mavlink nested-send lock path that was silently
|
||||
deadlockable on NuttX.
|
||||
|
||||
```
|
||||
./dual_link_forwarding.py CONNECTION CONNECTION2 [-b BAUD] [--baudrate2 BAUD2] [--duration S]
|
||||
```
|
||||
|
||||
Verifies heartbeats on both links, then runs 60s of sustained bidirectional
|
||||
traffic (a silent gap > 5s on either link is FAIL), then a full param download
|
||||
over the radio link WHILE a shell session runs over USB, then proves both links
|
||||
are still alive afterward. PASS means every stage completed and both links
|
||||
survived. A hang anywhere reports FAIL naming the stage instead of blocking.
|
||||
|
||||
### param_torture
|
||||
|
||||
Purpose: hammer the parameter subsystem locking.
|
||||
|
||||
```
|
||||
./param_torture.py CONNECTION [-b BAUD] [--iterations N] [--param NAME] [--skip-reboot]
|
||||
```
|
||||
|
||||
Full param download, then 50 set/readback iterations on `SDLOG_UTC_OFFSET` (a
|
||||
harmless scratch param, restored afterward), then param save + reboot +
|
||||
persistence verification. PASS means every readback matched, the value
|
||||
persisted across reboot, and the original value was restored. A FAIL names the
|
||||
iteration or the persistence step. Use `--skip-reboot` to avoid the reboot
|
||||
stage.
|
||||
|
||||
### mission_torture
|
||||
|
||||
Purpose: exercise dataman and the new mission shared-state mutex.
|
||||
|
||||
```
|
||||
./mission_torture.py CONNECTION [CONNECTION2] [-b BAUD] [--baudrate2 BAUD2] [--iterations N] [--items K]
|
||||
```
|
||||
|
||||
220-waypoint mission upload/download/compare/clear, 10 iterations. With two
|
||||
links it alternates uploads between the links. PASS means every download matched
|
||||
the upload across all iterations. A FAIL names the iteration and the mismatch,
|
||||
or the operation that stalled.
|
||||
|
||||
### mavftp_log
|
||||
|
||||
Purpose: exercise the logger and the MAVFTP path.
|
||||
|
||||
```
|
||||
./mavftp_log.py CONNECTION [-b BAUD] [--log-duration S] [--outdir DIR]
|
||||
```
|
||||
|
||||
Starts a short log via `logger on` / `logger off` (no arming), downloads the
|
||||
newest .ulg via MAVFTP, and verifies ULog integrity (pyulog if installed, magic
|
||||
bytes otherwise). PASS means the log was created, downloaded, and passed the
|
||||
integrity check. A FAIL names the stage; a stalled MAVFTP transfer is the
|
||||
finding.
|
||||
PASS: no task in ERROR state, required work queues present
|
||||
(`wq:rate_ctrl,wq:hp_default,wq:lp_default` by default), topics publishing.
|
||||
Baseline mode diffs two report dirs offline: uORB rate drift beyond
|
||||
tolerance, lost topics, or lost work queues are FAIL.
|
||||
|
||||
### reboot_loop
|
||||
|
||||
Purpose: exercise boot-time initialization ordering, where locked zero-init
|
||||
mutex bugs bite.
|
||||
|
||||
```
|
||||
./reboot_loop.py CONNECTION [-b BAUD] [--iterations N] [--cycle-timeout S]
|
||||
./bench/reboot_loop.py CONNECTION [-b BAUD] [--iterations N] [--cycle-timeout S]
|
||||
```
|
||||
|
||||
10 reboot/reconnect cycles via `MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN`. PASS means
|
||||
the board came back within `--cycle-timeout` on every cycle. Any failure to
|
||||
reconnect in time is FAIL with the elapsed time and the cycle number.
|
||||
Reboots via `MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN` (default 10 cycles). Each
|
||||
cycle must produce a heartbeat and a completed shell command within the
|
||||
timeout; a board that never comes back aborts the loop with the elapsed time.
|
||||
|
||||
### run_bench_suite
|
||||
|
||||
Runs all non-interactive tests in sequence with a per-test watchdog.
|
||||
### usb_replug (operator-assisted)
|
||||
|
||||
```
|
||||
./run_bench_suite.py CONNECTION [CONNECTION2] [-b BAUD] [--skip NAMES] [--report-dir DIR] [--per-test-timeout S] [--stop-on-fail]
|
||||
./bench/usb_replug.py CONNECTION [-b BAUD] [--cycles M] [--heartbeat-timeout S] [--ram-tolerance BYTES]
|
||||
```
|
||||
|
||||
Default per-test watchdog is 900s; a test that exceeds it is killed and recorded
|
||||
as FAIL `<test> hung`. `usb_replug` is operator-assisted and must be run
|
||||
manually. Dual-link tests run only when a second connection is given.
|
||||
Prints UNPLUG/REPLUG instructions and detects the device node vanishing and
|
||||
returning programmatically (no keyboard needed mid-test). PASS per cycle:
|
||||
heartbeat back within the timeout, mavlink instance count equal to baseline,
|
||||
free RAM within budget.
|
||||
|
||||
### sih_flight.py
|
||||
|
||||
Scripted SIH flight on the FMU itself (SYS_HITL=2, physics on the autopilot):
|
||||
switches to a SIH airframe, clears any stored mission (an identical re-upload
|
||||
would match the stored mission CRC and keep finished=true), uploads takeoff +
|
||||
3-waypoint square + RTL, arms via the nsh commander, and asserts arming,
|
||||
takeoff, waypoint progression, landing, and auto-disarm with per-phase
|
||||
timeouts. The flight ULog is downloaded into the report directory afterwards,
|
||||
also on failure, since post-flight verification starts from the log. The
|
||||
original SYS_AUTOSTART/SYS_HITL are restored with an explicit `param save`
|
||||
(autosave races an immediate reboot) unless `--keep-config` is given.
|
||||
### link_forwarding (needs 2 links)
|
||||
|
||||
```
|
||||
./sih_flight.py CONNECTION [-b BAUD] [--airframe N] [--alt M] [--viewer] [--keep-config]
|
||||
./bench/link_forwarding.py CONNECTION CONNECTION2 [-b BAUD] [--baudrate2 BAUD2] [--duration S]
|
||||
```
|
||||
|
||||
Requires firmware built with `CONFIG_MODULES_SIMULATION_SIMULATOR_SIH=y`.
|
||||
Real outputs are replaced by `pwm_out_sim` in HITL, so nothing is driven on
|
||||
the output rails; still, fly it on a bench board with nothing connected.
|
||||
Run standalone; it is intentionally not part of `run_bench_suite.py` because
|
||||
it reboots the board into a different airframe twice.
|
||||
Four phases: liveness on both links, sustained bidirectional traffic
|
||||
(silent gap > 5s = FAIL), the nested-send hammer (full param download over
|
||||
the radio while an nsh shell session runs over USB), and post-stress
|
||||
liveness on both links. A hang in any phase is a FAIL naming the phase.
|
||||
|
||||
With `--viewer`, every received MAVLink frame is teed to UDP 19410 (one frame
|
||||
per datagram, the SITL viewer framing) and the HIL_STATE_QUATERNION /
|
||||
HIL_ACTUATOR_CONTROLS streams are enabled on the board, so a locally running
|
||||
[Hawkeye](https://github.com/PX4/Hawkeye) (`hawkeye -udp 19410 -mc`) renders
|
||||
the flight live.
|
||||
### param_stress
|
||||
|
||||
```
|
||||
./bench/param_stress.py CONNECTION [-b BAUD] [--iterations N] [--param NAME] [--skip-reboot]
|
||||
```
|
||||
|
||||
Full param download, then a set/readback loop (default 50) on
|
||||
`SDLOG_UTC_OFFSET` (harmless scratch param, always restored), then
|
||||
`param save` + reboot + persistence verification.
|
||||
|
||||
### mission_stress
|
||||
|
||||
```
|
||||
./bench/mission_stress.py CONNECTION [CONNECTION2] [-b BAUD] [--baudrate2 BAUD2] [--iterations N] [--items K]
|
||||
```
|
||||
|
||||
220-item mission upload/download/item-by-item compare/clear/verify-cleared,
|
||||
default 10 iterations, alternating links when two are given.
|
||||
|
||||
### log_transfer
|
||||
|
||||
```
|
||||
./bench/log_transfer.py CONNECTION [-b BAUD] [--log-duration S] [--report-dir DIR]
|
||||
```
|
||||
|
||||
`logger on`, record, `logger off` (no arming), then download the newest
|
||||
.ulg via MAVFTP and verify it (pyulog when available, ULog magic bytes
|
||||
otherwise). A stalled transfer reports the byte count it reached.
|
||||
|
||||
## SIH test (simulation on the FMU)
|
||||
|
||||
### flight_mission
|
||||
|
||||
```
|
||||
./sih/flight_mission.py CONNECTION [-b BAUD] [--airframe N] [--alt M]
|
||||
[--viewer] [--viewer-port PORT] [--keep-config]
|
||||
[--board-dev DEV] [--report-dir DIR]
|
||||
```
|
||||
|
||||
Switches the board to a SIH airframe (`SYS_AUTOSTART=1100`, `SYS_HITL=2`,
|
||||
physics simulated on the FMU, `pwm_out_sim` in place of real outputs), flies
|
||||
takeoff, a 3-waypoint square, and RTL as an auto mission with per-phase
|
||||
timeouts (arming, airborne, waypoint progression, touchdown, auto-disarm),
|
||||
downloads the flight ULog, and restores the original configuration even on
|
||||
failure. `--viewer` tees every MAVLink frame to UDP so Hawkeye
|
||||
(`hawkeye -udp 19410 -mc`) renders the flight live from the serial-connected
|
||||
board.
|
||||
|
||||
This is deliberately separated from `bench/`: it reconfigures the board and
|
||||
exercises simulated flight logic, while the bench tests exercise real
|
||||
firmware paths without simulation.
|
||||
|
||||
## Baseline workflow
|
||||
|
||||
Use this to compare pre-upgrade and post-upgrade firmware. Each capture
|
||||
creates a timestamped directory under `--report-dir` and prints its path
|
||||
(`report dir: ...`); the diff takes those printed paths, not the base
|
||||
directory:
|
||||
Each `boot_health` capture creates a timestamped directory under
|
||||
`--report-dir` and prints its path; the diff takes those printed paths:
|
||||
|
||||
1. On known-good firmware, capture a report:
|
||||
`./boot_health.py CONNECTION` and note the printed report dir, for
|
||||
example `bench_reports/20260706T164512Z_boot_health`.
|
||||
1. On known-good firmware: `./bench/boot_health.py CONNECTION`, note the
|
||||
printed report dir.
|
||||
2. Flash the candidate firmware.
|
||||
3. Capture again: `./boot_health.py CONNECTION` and note the new report dir.
|
||||
4. Diff them:
|
||||
`./boot_health.py --baseline <old report dir> <new report dir> [--tolerance PCT]`
|
||||
3. Capture again, note the new report dir.
|
||||
4. `./bench/boot_health.py --baseline <old dir> <new dir> [--tolerance PCT]`
|
||||
|
||||
The diff checks that uorb rates are within tolerance and the work queue sets are
|
||||
equal. A missing work queue or a rate outside tolerance is FAIL.
|
||||
## Adding a new test
|
||||
|
||||
Conventions the suite relies on; new tests should follow all of them:
|
||||
|
||||
- Use `px4bench.Reporter` for every check; end with
|
||||
`sys.exit(report.finish())` so the exit code reflects the result.
|
||||
- Use `px4bench.add_connection_args(parser)` so the CLI surface stays
|
||||
uniform (`CONNECTION`, `--baudrate/-b`, `--connect-timeout`; add
|
||||
`--report-dir` if the test writes artifacts).
|
||||
- Every wait has a timeout. A timeout is a FAIL whose detail names what
|
||||
stalled (command, phase, seq, byte count). Never block forever: a hung
|
||||
test hides the hang it was supposed to expose.
|
||||
- Restore any board state you change (params, airframe), in a `finally`,
|
||||
even when the test failed.
|
||||
- No arming in `bench/`. Anything that flies (simulated) goes in `sih/`.
|
||||
- Reuse the library: connection and shell primitives from `px4bench`,
|
||||
protocol helpers from `px4bench.params` / `.missions` / `.ftp`. If two
|
||||
tests need the same helper, it moves into the library.
|
||||
- Shebang, exec bit, and the parent-dir path shim so the script runs
|
||||
without installation; add non-interactive tests to `run_bench_suite.py`.
|
||||
|
||||
Two behaviors are hardware-learned and must not be "simplified" away:
|
||||
parameter echoes are drained before a set and matched by expected value
|
||||
(PX4 emits multiple PARAM_VALUE per set), and the shell strips every
|
||||
BENCHDONE sentinel from captured output (the second safety echo of the
|
||||
previous command can arrive late).
|
||||
|
||||
## Safety
|
||||
|
||||
- Bench use only. Props off.
|
||||
- The suite never arms the vehicle.
|
||||
- `param_torture` and `reboot_loop` reboot the board multiple times. Expect the
|
||||
link to drop and return.
|
||||
- `param_torture` writes only to `SDLOG_UTC_OFFSET` and restores the original
|
||||
value automatically.
|
||||
- `bench/` never arms the vehicle. `sih/flight_mission.py` arms only in
|
||||
simulation (`SYS_HITL=2`, `pwm_out_sim`); nothing is driven on the output
|
||||
rails.
|
||||
- `param_stress`, `reboot_loop`, and `flight_mission` reboot the board.
|
||||
Expect the link to drop and return.
|
||||
- Scratch params and airframe configuration are restored automatically,
|
||||
including on failure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -257,10 +274,9 @@ No heartbeat:
|
||||
- Wrong baud on a radio link. Match the radio's configured baud with `-b` /
|
||||
`--baudrate2`.
|
||||
|
||||
Shell stalls: if `boot_health` or a shell-based stage hangs, that stall is the
|
||||
finding, not a tooling bug. Note which command it stalled on and keep the report
|
||||
dir.
|
||||
Shell stalls: if a shell-based stage hangs, that stall is the finding, not a
|
||||
tooling bug. Note which command it stalled on and keep the report dir.
|
||||
|
||||
Telemetry radio bandwidth: dual-link traffic, param download, and mission
|
||||
upload/download over a radio link are much slower than over USB. Allow longer
|
||||
durations (`--duration`, `--per-test-timeout`) when a radio is in the path.
|
||||
transfer over a radio are much slower than USB. Allow longer durations
|
||||
(`--duration`, `--per-test-timeout`) when a radio is in the path.
|
||||
|
||||
@@ -24,7 +24,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import px4bench
|
||||
|
||||
|
||||
@@ -58,10 +58,6 @@ def save_output(report_dir, cmd, output):
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def top_has_error_task(output):
|
||||
"""Return (has_error, list_of_offending_lines).
|
||||
|
||||
@@ -99,14 +95,6 @@ def parse_work_queues(output):
|
||||
return names
|
||||
|
||||
|
||||
def count_mavlink_instances(output):
|
||||
"""Count MAVLink instances from `mavlink status`.
|
||||
|
||||
Each instance block begins with `instance #N:` (mavlink_main.cpp:535).
|
||||
"""
|
||||
return len(re.findall(r'instance\s*#\s*\d+', output))
|
||||
|
||||
|
||||
def parse_uorb_top(output):
|
||||
"""Parse `uorb top -1` output into {topic_key: rate}.
|
||||
|
||||
@@ -141,10 +129,6 @@ def parse_uorb_top(output):
|
||||
return rates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capture mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_capture(args, report):
|
||||
report_dir = px4bench.make_report_dir(args.report_dir, 'boot_health')
|
||||
report.info('report dir: {}'.format(report_dir))
|
||||
@@ -202,7 +186,7 @@ def run_capture(args, report):
|
||||
|
||||
# mavlink instances: informational count
|
||||
if 'mavlink status' in outputs:
|
||||
n = count_mavlink_instances(outputs['mavlink status'])
|
||||
n = px4bench.count_mavlink_instances(outputs['mavlink status'])
|
||||
report.info('mavlink instances: {}'.format(n))
|
||||
|
||||
shell.close()
|
||||
@@ -210,10 +194,6 @@ def run_capture(args, report):
|
||||
return report_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baseline mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_saved(report_dir, cmd):
|
||||
"""Read a previously saved command output from a report dir, or ''."""
|
||||
path = os.path.join(report_dir, sanitize_cmd(cmd) + '.txt')
|
||||
@@ -290,10 +270,6 @@ def run_baseline(old_dir, new_dir, tolerance, report):
|
||||
report.info('new work queues: {}'.format(', '.join(added)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PX4 boot-health snapshot and offline baseline diff.')
|
||||
+8
-37
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dual-link forwarding stress test for the mavlink nested-send lock path.
|
||||
Dual-link forwarding stress test for the mavlink nested-send lock path
|
||||
(link_forwarding).
|
||||
|
||||
This is the flagship bench test for qualifying PX4 v1.18 on real NuttX
|
||||
hardware after the mavlink locking rework. The rework changed how the
|
||||
@@ -26,11 +27,10 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from px4bench import Reporter, MavlinkShell, add_connection_args, connect
|
||||
|
||||
from pymavlink import mavutil
|
||||
from px4bench import (Reporter, MavlinkShell, add_connection_args, connect,
|
||||
parse_mavlink_status, send_heartbeat)
|
||||
|
||||
|
||||
HEARTBEAT_INTERVAL = 1.0 # GCS -> autopilot heartbeat cadence, seconds
|
||||
@@ -42,11 +42,6 @@ PARAM_HARD_CAP = 120.0 # absolute cap on the param download, seconds
|
||||
GLOBAL_BUDGET_EXTRA = 300.0 # wall-clock budget = duration + this
|
||||
|
||||
|
||||
def send_heartbeat(mav):
|
||||
mav.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
||||
|
||||
|
||||
class ParamDownloader(threading.Thread):
|
||||
"""Full parameter download over one link, run in a daemon worker thread.
|
||||
|
||||
@@ -147,30 +142,6 @@ class LinkPump(threading.Thread):
|
||||
pass
|
||||
|
||||
|
||||
def parse_status_instances(text):
|
||||
"""Parse `mavlink status` output into a list of per-instance dicts with
|
||||
tx/rx B/s. Blocks start with a line containing 'instance #'.
|
||||
"""
|
||||
instances = []
|
||||
cur = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if 'instance #' in stripped:
|
||||
if cur is not None:
|
||||
instances.append(cur)
|
||||
cur = {'header': stripped, 'tx': None, 'rx': None}
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if stripped.startswith('tx:') and cur['tx'] is None:
|
||||
cur['tx'] = stripped
|
||||
elif stripped.startswith('rx:') and cur['rx'] is None:
|
||||
cur['rx'] = stripped
|
||||
if cur is not None:
|
||||
instances.append(cur)
|
||||
return instances
|
||||
|
||||
|
||||
def phase1_liveness(report, mav1, mav2, global_deadline):
|
||||
"""Independent heartbeat check on each link. FAIL names the dead link."""
|
||||
report.info('Phase 1: liveness on both links')
|
||||
@@ -317,7 +288,7 @@ def phase4_post_liveness(report, mav1, mav2, global_deadline):
|
||||
if timed_out:
|
||||
report.fail('phase4_shell_stall', '`mavlink status` over link1 stalled after stress')
|
||||
return False
|
||||
instances = parse_status_instances(out)
|
||||
instances = parse_mavlink_status(out)
|
||||
if not instances:
|
||||
report.fail('phase4_status_parse', 'could not parse any instance block from mavlink status')
|
||||
return False
|
||||
@@ -341,11 +312,11 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.connection2 is None:
|
||||
print('error: dual_link_forwarding requires a second connection (CONNECTION2)',
|
||||
print('error: link_forwarding requires a second connection (CONNECTION2)',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
report = Reporter('dual_link_forwarding')
|
||||
report = Reporter('link_forwarding')
|
||||
global_deadline = time.monotonic() + args.duration + GLOBAL_BUDGET_EXTRA
|
||||
|
||||
mav1 = None
|
||||
@@ -22,112 +22,29 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from px4bench import Reporter, MavlinkShell, add_connection_args, connect, make_report_dir
|
||||
from px4bench import (Reporter, MavlinkShell, add_connection_args, connect,
|
||||
make_report_dir, send_heartbeat)
|
||||
from px4bench.ftp import LOG_ROOT, ULOG_MAGIC7, ftp_list, ftp_download
|
||||
|
||||
from pymavlink import mavutil
|
||||
from pymavlink import mavftp
|
||||
|
||||
|
||||
LOG_ROOT = '/fs/microsd/log'
|
||||
# ULog file header magic: 'U' 'L' 'o' 'g' 0x01 0x12 0x35, then a file-version
|
||||
# byte. See src/modules/logger/logger.cpp:2099-2106.
|
||||
ULOG_MAGIC7 = bytes([0x55, 0x4C, 0x6F, 0x67, 0x01, 0x12, 0x35])
|
||||
FTP_BUDGET = 300.0 # overall MAVFTP budget in seconds
|
||||
SHELL_TIMEOUT = 10.0
|
||||
MIN_LOG_SIZE = 1024 # a real ULog is more than 1 KB
|
||||
|
||||
|
||||
def send_heartbeat(mav):
|
||||
mav.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
||||
|
||||
|
||||
def ftp_list(ftp, path):
|
||||
"""List a remote directory. cmd_list drives its own reply loop internally
|
||||
and populates ftp.list_result. Returns the list of DirectoryEntry.
|
||||
"""
|
||||
ftp.cmd_list([path])
|
||||
return list(ftp.list_result)
|
||||
|
||||
|
||||
def ftp_download(mav, ftp, remote, local, report):
|
||||
"""Download one remote file to a local path over MAVFTP, bounded by
|
||||
FTP_BUDGET and reporting a stall by byte count.
|
||||
|
||||
cmd_get only sends OpenFileRO and returns; the transfer itself is driven
|
||||
by process_ftp_reply, which loops recv_match -> __mavlink_packet (burst
|
||||
read handling, temp-file writes) -> __idle_task until idle or timeout.
|
||||
We use a completion callback so we know the burst read finished, and we
|
||||
pump process_ftp_reply in bounded slices so a stalled burst becomes a
|
||||
named FAIL instead of a hang. process_ftp_reply requires its timeout to
|
||||
be greater than idle_detection_time (default 3.7s).
|
||||
|
||||
Returns (elapsed_seconds, None) on success, (None, error_string) on
|
||||
failure, so the caller can branch on the error explicitly instead of
|
||||
ever mixing a message string into arithmetic.
|
||||
"""
|
||||
done = {'ok': False}
|
||||
|
||||
def on_complete(fh):
|
||||
# fh is a BytesIO on success, or None if the session was terminated
|
||||
# (failure). cmd_get with a callback keeps the payload in memory, so we
|
||||
# persist it here ourselves.
|
||||
if fh is None:
|
||||
return
|
||||
try:
|
||||
fh.seek(0)
|
||||
with open(local, 'wb') as out:
|
||||
out.write(fh.read())
|
||||
done['ok'] = True
|
||||
except Exception as e: # noqa: BLE001 - surface write failures to the caller
|
||||
report.info('local write failed: {}'.format(e))
|
||||
|
||||
ftp.cmd_get([remote], callback=on_complete)
|
||||
|
||||
start = time.monotonic()
|
||||
last_bytes = 0
|
||||
last_progress = start
|
||||
next_hb = 0.0
|
||||
# process_ftp_reply timeout must exceed idle_detection_time; use a short
|
||||
# slice and loop so we can enforce our own budget and stall detection.
|
||||
slice_timeout = max(4.0, ftp.ftp_settings.idle_detection_time + 0.5)
|
||||
while not done['ok']:
|
||||
now = time.monotonic()
|
||||
if now > next_hb:
|
||||
send_heartbeat(mav)
|
||||
next_hb = now + 1.0
|
||||
if now - start > FTP_BUDGET:
|
||||
cur = ftp.read_total if ftp.read_total else last_bytes
|
||||
return None, 'MAVFTP transfer stalled at {} bytes (budget {:.0f}s)'.format(
|
||||
cur, FTP_BUDGET)
|
||||
# drive one slice of the FTP state machine
|
||||
ftp.process_ftp_reply('OpenFileRO', timeout=slice_timeout)
|
||||
cur = ftp.read_total
|
||||
if cur > last_bytes:
|
||||
last_bytes = cur
|
||||
last_progress = now
|
||||
elif now - last_progress > 30.0 and not done['ok']:
|
||||
# no forward progress for 30s and callback never fired
|
||||
return None, 'MAVFTP transfer stalled at {} bytes (no progress 30s)'.format(cur)
|
||||
# if the session ended without completing, process_ftp_reply keeps
|
||||
# returning immediately on idle; break out via the budget/stall checks
|
||||
elapsed = time.monotonic() - start
|
||||
return elapsed, None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Logger + MAVFTP download path test (v1.18 bench).')
|
||||
add_connection_args(parser)
|
||||
parser.add_argument('--log-duration', type=float, default=10,
|
||||
help='seconds to log before stopping (default: %(default)s)')
|
||||
parser.add_argument('--outdir', default='bench_reports',
|
||||
parser.add_argument('--report-dir', default='bench_reports',
|
||||
help='base directory for the downloaded log (default: %(default)s)')
|
||||
args = parser.parse_args()
|
||||
|
||||
report = Reporter('mavftp_log')
|
||||
report = Reporter('log_transfer')
|
||||
|
||||
mav = None
|
||||
try:
|
||||
@@ -211,7 +128,7 @@ def main():
|
||||
newest.name, newest.size_b))
|
||||
|
||||
remote = '{}/{}'.format(log_dir, newest.name)
|
||||
outdir = make_report_dir(args.outdir, 'mavftp_log')
|
||||
outdir = make_report_dir(args.report_dir, 'log_transfer')
|
||||
local = os.path.join(outdir, newest.name)
|
||||
|
||||
report.info('Downloading {} -> {}'.format(remote, local))
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mission upload/download/clear stress test for on-bench PX4 hardware.
|
||||
|
||||
v1.18 risk area: dataman and the mission shared-state path were reworked and
|
||||
a brand-new mutex now guards mission shared state (#27813). A regression
|
||||
there shows up as a SILENT HANG (a MISSION_REQUEST that never arrives, a
|
||||
MISSION_ACK that never comes, a download that stalls mid-list) or as a
|
||||
corrupted round-trip. Repetition is the test: single-shot operations do not
|
||||
hit races, so this script repeatedly uploads a large mission, reads it back
|
||||
and compares it item-by-item, then clears it, with a hard timeout on every
|
||||
wait so a stall becomes a FAIL naming the stalled step and the seq reached.
|
||||
|
||||
With a second connection given, iterations alternate between the two links
|
||||
(iteration parity picks the link) to exercise the new mission shared-state
|
||||
mutex from two MAVLink channels.
|
||||
|
||||
Usage:
|
||||
mission_stress.py CONNECTION [CONNECTION2] [-b BAUD]
|
||||
[--baudrate2 BAUD2] [--iterations N] [--items K]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import px4bench
|
||||
from px4bench import missions
|
||||
|
||||
DEFAULT_ITERATIONS = 10
|
||||
DEFAULT_ITEMS = 220 # <= smallest CONFIG_NUM_MISSION_ITMES_SUPPORTED (500)
|
||||
DEFAULT_BAUD2 = 57600
|
||||
|
||||
|
||||
def run_iteration(report, mav, iteration, items_k, link_label):
|
||||
"""One full upload/download/compare/clear cycle. Returns True on full pass."""
|
||||
expected = missions.generate_mission(iteration, items_k)
|
||||
|
||||
up_ok, up_dur, up_detail = missions.upload_mission(report, mav, expected, iteration)
|
||||
if not report.check('iter{}_upload'.format(iteration), up_ok,
|
||||
'[{}] {} ({:.1f}s)'.format(link_label, up_detail, up_dur)):
|
||||
return False
|
||||
|
||||
downloaded, dl_dur, dl_detail = missions.download_mission(mav)
|
||||
if downloaded is None:
|
||||
report.fail('iter{}_download'.format(iteration),
|
||||
'[{}] {} ({:.1f}s)'.format(link_label, dl_detail, dl_dur))
|
||||
return False
|
||||
|
||||
cmp_ok, cmp_detail = missions.compare_mission(expected, downloaded)
|
||||
if not report.check('iter{}_compare'.format(iteration), cmp_ok,
|
||||
'[{}] {}'.format(link_label, cmp_detail)):
|
||||
return False
|
||||
|
||||
clr_ok, clr_detail = missions.clear_mission(mav)
|
||||
if not report.check('iter{}_clear'.format(iteration), clr_ok,
|
||||
'[{}] {}'.format(link_label, clr_detail)):
|
||||
return False
|
||||
|
||||
vfy_ok, vfy_detail = missions.verify_cleared(mav)
|
||||
report.check('iter{}_verify_clear'.format(iteration), vfy_ok,
|
||||
'[{}] {}'.format(link_label, vfy_detail))
|
||||
|
||||
if up_ok and downloaded is not None and cmp_ok and clr_ok and vfy_ok:
|
||||
report.info('iter {} PASS [{}] upload {:.1f}s download {:.1f}s'.format(
|
||||
iteration, link_label, up_dur, dl_dur))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
px4bench.add_connection_args(parser, dual_link=True)
|
||||
parser.add_argument('--baudrate2', type=int, default=DEFAULT_BAUD2,
|
||||
help='baud rate for the second connection (default: %(default)s)')
|
||||
parser.add_argument('--iterations', type=int, default=DEFAULT_ITERATIONS,
|
||||
help='upload/download/clear iterations (default: %(default)s)')
|
||||
parser.add_argument('--items', type=int, default=DEFAULT_ITEMS,
|
||||
help='mission items per iteration (default: %(default)s)')
|
||||
args = parser.parse_args()
|
||||
|
||||
report = px4bench.Reporter('mission_stress')
|
||||
|
||||
try:
|
||||
mav1 = px4bench.connect(args.connection, baud=args.baudrate,
|
||||
timeout=args.connect_timeout)
|
||||
except (TimeoutError, OSError) as e:
|
||||
report.fail('connect', 'link 1 {}: {}'.format(args.connection, e))
|
||||
sys.exit(report.finish())
|
||||
report.info('link 1 connected to system {} component {}'.format(
|
||||
mav1.target_system, mav1.target_component))
|
||||
|
||||
mav2 = None
|
||||
if args.connection2:
|
||||
try:
|
||||
mav2 = px4bench.connect(args.connection2, baud=args.baudrate2,
|
||||
timeout=args.connect_timeout)
|
||||
report.info('link 2 connected to system {} component {}'.format(
|
||||
mav2.target_system, mav2.target_component))
|
||||
except (TimeoutError, OSError) as e:
|
||||
report.fail('connect', 'link 2 {}: {}'.format(args.connection2, e))
|
||||
sys.exit(report.finish())
|
||||
|
||||
try:
|
||||
for iteration in range(args.iterations):
|
||||
if mav2 is not None and (iteration % 2 == 1):
|
||||
mav, label = mav2, 'link2'
|
||||
else:
|
||||
mav, label = mav1, 'link1'
|
||||
run_iteration(report, mav, iteration, args.items, label)
|
||||
finally:
|
||||
for m in (mav1, mav2):
|
||||
try:
|
||||
if m is not None:
|
||||
m.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(report.finish())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parameter-subsystem torture test for on-bench PX4 hardware.
|
||||
Parameter-subsystem stress test for on-bench PX4 hardware.
|
||||
|
||||
v1.18 risk area: the parameters backend received a concurrency and locking
|
||||
rework. A regression there shows up as a SILENT HANG (a PARAM_VALUE that
|
||||
never arrives, a full download that stalls halfway) or as a dropped write
|
||||
that only surfaces after a reboot. This script hammers the parameter
|
||||
protocol over MAVLink with hard timeouts on every wait, so a stall becomes
|
||||
a named FAIL instead of a hung process.
|
||||
rework (DynamicSparseLayer races, AtomicTransaction). A regression there
|
||||
shows up as a SILENT HANG (a PARAM_VALUE that never arrives, a full download
|
||||
that stalls halfway) or as a dropped write that only surfaces after a
|
||||
reboot. Repetition is the test: single-shot operations do not hit races, so
|
||||
this script hammers the parameter protocol over MAVLink with hard timeouts
|
||||
on every wait, and a stall becomes a named FAIL instead of a hung process.
|
||||
|
||||
Three phases:
|
||||
1. Full parameter download (PARAM_REQUEST_LIST), stall-detected.
|
||||
@@ -19,20 +20,22 @@ timestamps, so it is safe to thrash on a bench. Its original value is
|
||||
always restored while a connection is alive, even on failure.
|
||||
|
||||
Usage:
|
||||
param_torture.py CONNECTION [-b BAUD] [--iterations N]
|
||||
[--param NAME] [--skip-reboot]
|
||||
param_stress.py CONNECTION [-b BAUD] [--iterations N]
|
||||
[--param NAME] [--skip-reboot]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import px4bench
|
||||
from px4bench import mavutil
|
||||
from px4bench.params import (READ_TIMEOUT_S, SET_ECHO_TIMEOUT_S,
|
||||
drain_param_values, param_float_to_int32,
|
||||
param_id_str, read_param, set_param_int32,
|
||||
wait_param_echo)
|
||||
|
||||
DEFAULT_PARAM = 'SDLOG_UTC_OFFSET'
|
||||
DEFAULT_ITERATIONS = 50
|
||||
@@ -40,116 +43,10 @@ DEFAULT_ITERATIONS = 50
|
||||
# Persistence-phase marker value (within SDLOG_UTC_OFFSET min/max -1000..1000).
|
||||
MARKER_VALUE = 777
|
||||
|
||||
MAV_PARAM_TYPE_INT32 = mavutil.mavlink.MAV_PARAM_TYPE_INT32
|
||||
|
||||
# Download stall thresholds.
|
||||
DOWNLOAD_STALL_S = 10.0 # FAIL if no new param index for this long
|
||||
DOWNLOAD_OVERALL_CAP_S = 180.0
|
||||
|
||||
# Per-message wait timeouts.
|
||||
SET_ECHO_TIMEOUT_S = 5.0
|
||||
READ_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INT32 <-> float union (byte-wise) encoding.
|
||||
#
|
||||
# PX4 transports an INT32 parameter as the raw bit pattern of the int placed
|
||||
# into the float param_value field. To send: pack the int as '<i', reinterpret
|
||||
# those 4 bytes as '<f'. To receive: pack the received float as '<f',
|
||||
# reinterpret as '<i'. This mirrors the union trick the firmware uses; the
|
||||
# param float is never a numeric conversion of the int.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def int32_to_param_float(value):
|
||||
"""Reinterpret an int32 bit pattern as the float carried in param_value."""
|
||||
return struct.unpack('<f', struct.pack('<i', int(value)))[0]
|
||||
|
||||
|
||||
def param_float_to_int32(value):
|
||||
"""Reinterpret a param_value float's bit pattern as an int32."""
|
||||
return struct.unpack('<i', struct.pack('<f', float(value)))[0]
|
||||
|
||||
|
||||
def param_id_str(raw):
|
||||
"""Normalize a PARAM_VALUE.param_id (bytes or str) to a plain string."""
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode('ascii', errors='replace')
|
||||
return raw.rstrip('\x00')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def request_param_read(mav, name):
|
||||
"""Send PARAM_REQUEST_READ by name (param_index = -1)."""
|
||||
mav.mav.param_request_read_send(
|
||||
mav.target_system, mav.target_component,
|
||||
name.encode('ascii'), -1)
|
||||
|
||||
|
||||
def read_param(mav, name, timeout=READ_TIMEOUT_S):
|
||||
"""PARAM_REQUEST_READ then wait for the matching PARAM_VALUE.
|
||||
|
||||
Returns (int32_value, raw_float) or (None, None) on timeout.
|
||||
"""
|
||||
request_param_read(mav, name)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
m = mav.recv_match(type='PARAM_VALUE', blocking=True,
|
||||
timeout=max(0.1, deadline - time.monotonic()))
|
||||
if m is None:
|
||||
continue
|
||||
if param_id_str(m.param_id) == name:
|
||||
return param_float_to_int32(m.param_value), m.param_value
|
||||
return None, None
|
||||
|
||||
|
||||
def set_param_int32(mav, name, value):
|
||||
"""PARAM_SET an int32 using the union encoding."""
|
||||
mav.mav.param_set_send(
|
||||
mav.target_system, mav.target_component,
|
||||
name.encode('ascii'),
|
||||
int32_to_param_float(value),
|
||||
MAV_PARAM_TYPE_INT32)
|
||||
|
||||
|
||||
def drain_param_values(mav):
|
||||
"""Discard any queued PARAM_VALUE messages (stale echoes/broadcasts)."""
|
||||
while mav.recv_match(type='PARAM_VALUE', blocking=False) is not None:
|
||||
pass
|
||||
|
||||
|
||||
def wait_param_echo(mav, name, expected, timeout=SET_ECHO_TIMEOUT_S):
|
||||
"""Wait for a PARAM_VALUE for name carrying the expected int32 value.
|
||||
|
||||
PX4 can emit more than one PARAM_VALUE per set (handler reply plus the
|
||||
changed-param announcement, times the number of mavlink instances), so
|
||||
consuming a single message desyncs the harness by one echo forever.
|
||||
Returns (matched, seen) where seen is every value observed for name; a
|
||||
genuine wrong-echo firmware bug shows up as matched=False with the wrong
|
||||
value(s) in seen.
|
||||
"""
|
||||
seen = []
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
m = mav.recv_match(type='PARAM_VALUE', blocking=True,
|
||||
timeout=max(0.1, deadline - time.monotonic()))
|
||||
if m is None:
|
||||
continue
|
||||
if param_id_str(m.param_id) != name:
|
||||
continue
|
||||
value = param_float_to_int32(m.param_value)
|
||||
seen.append(value)
|
||||
if value == expected:
|
||||
return True, seen
|
||||
return False, seen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: full download
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def phase_full_download(report, mav):
|
||||
"""PARAM_REQUEST_LIST; collect until param_count reached, stall-detected.
|
||||
@@ -229,10 +126,6 @@ def phase_full_download(report, mav):
|
||||
return by_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: set / readback loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def phase_set_readback(report, mav, param, iterations):
|
||||
"""Loop set/readback with a value that changes every iteration.
|
||||
|
||||
@@ -278,10 +171,6 @@ def phase_set_readback(report, mav, param, iterations):
|
||||
iterations, echo_failures, read_failures, mismatch_failures))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: persistence across reboot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def phase_persistence(report, mav, param, conn_str, baud, original_value):
|
||||
"""Write a marker, save, reboot, verify it survived, then restore.
|
||||
|
||||
@@ -339,10 +228,6 @@ def phase_persistence(report, mav, param, conn_str, baud, original_value):
|
||||
return mav
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
@@ -356,7 +241,7 @@ def main():
|
||||
help='skip the reboot persistence phase')
|
||||
args = parser.parse_args()
|
||||
|
||||
report = px4bench.Reporter('param_torture')
|
||||
report = px4bench.Reporter('param_stress')
|
||||
|
||||
try:
|
||||
mav = px4bench.connect(args.connection, baud=args.baudrate,
|
||||
@@ -19,7 +19,7 @@ import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import px4bench
|
||||
|
||||
|
||||
@@ -26,15 +26,10 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import px4bench
|
||||
|
||||
|
||||
def count_mavlink_instances(output):
|
||||
"""Count MAVLink instances from `mavlink status` (instance #N blocks)."""
|
||||
return len(re.findall(r'instance\s*#\s*\d+', output))
|
||||
|
||||
|
||||
def parse_free_used(output):
|
||||
"""Parse NuttX `free` output, return (total_bytes, used_bytes) or (None, None).
|
||||
|
||||
@@ -89,7 +84,7 @@ def read_baseline(shell, report):
|
||||
report.fail('baseline mavlink status',
|
||||
'shell command mavlink status stalled (no completion within 10s)')
|
||||
return None
|
||||
instances = count_mavlink_instances(ms_out)
|
||||
instances = px4bench.count_mavlink_instances(ms_out)
|
||||
|
||||
free_out, timed_out = shell.run('free', timeout=10)
|
||||
if timed_out:
|
||||
@@ -131,7 +126,6 @@ def main():
|
||||
|
||||
device = args.connection
|
||||
|
||||
# ----- baseline -----
|
||||
try:
|
||||
mav = px4bench.connect(device, baud=args.baudrate, timeout=args.connect_timeout)
|
||||
except (TimeoutError, OSError) as e:
|
||||
@@ -157,7 +151,6 @@ def main():
|
||||
|
||||
last_used = baseline['used']
|
||||
|
||||
# ----- replug cycles -----
|
||||
for i in range(1, args.cycles + 1):
|
||||
print('-' * 60, flush=True)
|
||||
print('Cycle {}/{}: UNPLUG the USB cable now.'.format(i, args.cycles), flush=True)
|
||||
@@ -215,7 +208,7 @@ def main():
|
||||
shell.close()
|
||||
mav.close()
|
||||
continue
|
||||
instances = count_mavlink_instances(ms_out)
|
||||
instances = px4bench.count_mavlink_instances(ms_out)
|
||||
report.check('cycle {} instances'.format(i),
|
||||
instances == baseline['instances'],
|
||||
'instances={} (baseline={})'.format(instances, baseline['instances']))
|
||||
@@ -241,7 +234,6 @@ def main():
|
||||
used - last_used))
|
||||
last_used = used
|
||||
|
||||
# ----- final summary -----
|
||||
print('-' * 60, flush=True)
|
||||
total_growth = last_used - baseline['used']
|
||||
report.info('free used: first={} bytes, last={} bytes, net growth={:+d} bytes '
|
||||
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared helpers for the PX4 bench-test suite (Tools/bench_test).
|
||||
"""px4bench: shared library for the PX4 bench-test suite (Tools/bench_test).
|
||||
|
||||
Provides MAVLink connection setup, a non-interactive nsh shell over
|
||||
SERIAL_CONTROL (reusing the pattern from Tools/mavlink_shell.py), reboot
|
||||
and USB re-detection helpers, and PASS/FAIL reporting.
|
||||
Core primitives every test builds on: MAVLink connection setup, PASS/FAIL
|
||||
reporting, a non-interactive nsh shell over SERIAL_CONTROL (pattern from
|
||||
Tools/mavlink_shell.py), reboot and USB re-detection helpers, a live viewer
|
||||
tee, and small parsers for `mavlink status` output.
|
||||
|
||||
Protocol-area helpers live in submodules:
|
||||
px4bench.params parameter read/set/echo with PX4's int32 union encoding
|
||||
px4bench.missions mission item generation and the upload/download protocol
|
||||
px4bench.ftp MAVFTP listing/download and ULog constants
|
||||
|
||||
Every operation takes a timeout: a hung board is a finding, not an excuse
|
||||
for a hung test script.
|
||||
@@ -53,10 +57,6 @@ USB_DEVICE_GLOB_DARWIN = '/dev/tty.usbmodem*'
|
||||
USB_DEVICE_GLOB_LINUX = '/dev/serial/by-id/*PX4*'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Reporter:
|
||||
"""Collects named PASS/FAIL checks and prints a summary.
|
||||
|
||||
@@ -117,10 +117,6 @@ def make_report_dir(base='bench_reports', test_name=''):
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_serial_device(conn_str):
|
||||
return conn_str.startswith('/dev/') or conn_str.upper().startswith('COM')
|
||||
|
||||
@@ -135,8 +131,7 @@ def connect(conn_str, baud=DEFAULT_BAUD, timeout: float = 20, source_system=254)
|
||||
source_component=mavutil.mavlink.MAV_COMP_ID_MISSIONPLANNER)
|
||||
assert isinstance(mav, mavutil.mavfile), 'unexpected connection type for {}'.format(conn_str)
|
||||
# announce ourselves so the autopilot streams to us
|
||||
mav.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
||||
send_heartbeat(mav)
|
||||
hb = mav.wait_heartbeat(timeout=int(timeout))
|
||||
if hb is None:
|
||||
mav.close()
|
||||
@@ -149,6 +144,12 @@ def wait_heartbeat(mav, timeout: float = 10):
|
||||
return mav.wait_heartbeat(timeout=int(timeout))
|
||||
|
||||
|
||||
def send_heartbeat(mav):
|
||||
"""Send one GCS heartbeat so the autopilot keeps streaming to us."""
|
||||
mav.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
||||
|
||||
|
||||
def drain(mav, duration=0.5):
|
||||
"""Read and discard pending messages for a short window."""
|
||||
deadline = time.monotonic() + duration
|
||||
@@ -157,9 +158,37 @@ def drain(mav, duration=0.5):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nsh shell over MAVLink SERIAL_CONTROL
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_mavlink_status(text):
|
||||
"""Parse `mavlink status` shell output into per-instance dicts.
|
||||
|
||||
Each instance block begins with `instance #N:` and contains rate lines
|
||||
`tx: X B/s` / `rx: X B/s` (src/modules/mavlink/mavlink_main.cpp). Returns
|
||||
a list of {'header', 'tx', 'rx'} dicts; its length is the instance count.
|
||||
"""
|
||||
instances = []
|
||||
cur = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if 'instance #' in stripped:
|
||||
if cur is not None:
|
||||
instances.append(cur)
|
||||
cur = {'header': stripped, 'tx': None, 'rx': None}
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if stripped.startswith('tx:') and cur['tx'] is None:
|
||||
cur['tx'] = stripped
|
||||
elif stripped.startswith('rx:') and cur['rx'] is None:
|
||||
cur['rx'] = stripped
|
||||
if cur is not None:
|
||||
instances.append(cur)
|
||||
return instances
|
||||
|
||||
|
||||
def count_mavlink_instances(text):
|
||||
"""Count MAVLink instances in `mavlink status` output."""
|
||||
return len(re.findall(r'instance\s*#\s*\d+', text))
|
||||
|
||||
|
||||
class MavlinkShell:
|
||||
"""Non-interactive nsh shell over SERIAL_CONTROL (dev 10).
|
||||
@@ -194,8 +223,7 @@ class MavlinkShell:
|
||||
"""Read pending SERIAL_CONTROL data into self.buf, keep heartbeats going."""
|
||||
now = time.monotonic()
|
||||
if now > self._next_heartbeat:
|
||||
self.mav.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
||||
send_heartbeat(self.mav)
|
||||
self._next_heartbeat = now + 1.0
|
||||
m = self.mav.recv_match(condition='SERIAL_CONTROL.count!=0',
|
||||
type='SERIAL_CONTROL', blocking=True, timeout=window)
|
||||
@@ -255,10 +283,6 @@ class MavlinkShell:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live viewer tee (Hawkeye)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def attach_viewer_tee(conn, host='127.0.0.1', port=19410):
|
||||
"""Forward every received MAVLink frame to a UDP endpoint, one frame per
|
||||
datagram (same framing the SITL viewer channel uses), so Hawkeye can
|
||||
@@ -285,10 +309,6 @@ def attach_viewer_tee(conn, host='127.0.0.1', port=19410):
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reboot / device replug helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def send_reboot(mav):
|
||||
"""Request an autopilot reboot (MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN, param1=1)."""
|
||||
mav.mav.command_long_send(
|
||||
@@ -379,11 +399,8 @@ def reboot_and_reconnect(mav, conn_str, baud=DEFAULT_BAUD, timeout=60):
|
||||
timeout, last_err))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common CLI plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_connection_args(parser, dual_link=False):
|
||||
"""Standard CLI surface shared by every test in the suite."""
|
||||
parser.add_argument('connection',
|
||||
help='MAVLink connection: serial device (/dev/tty.usbmodem01), '
|
||||
'udp:IP:PORT, or tcp:IP:PORT')
|
||||
@@ -0,0 +1,90 @@
|
||||
"""MAVFTP helpers: directory listing, bounded file download, ULog constants.
|
||||
|
||||
Built on pymavlink.mavftp. cmd_get only sends OpenFileRO and returns; the
|
||||
transfer itself is driven by process_ftp_reply, so ftp_download pumps it in
|
||||
bounded slices with its own budget and stall detection: a transfer that
|
||||
stops mid-file becomes an error naming the byte count, never a hang.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from . import send_heartbeat
|
||||
|
||||
LOG_ROOT = '/fs/microsd/log'
|
||||
# ULog file header magic: 'U' 'L' 'o' 'g' 0x01 0x12 0x35, then a file-version
|
||||
# byte. See src/modules/logger/logger.cpp:2099-2106.
|
||||
ULOG_MAGIC7 = bytes([0x55, 0x4C, 0x6F, 0x67, 0x01, 0x12, 0x35])
|
||||
FTP_BUDGET = 300.0 # overall MAVFTP budget in seconds
|
||||
|
||||
|
||||
def ftp_list(ftp, path):
|
||||
"""List a remote directory. cmd_list drives its own reply loop internally
|
||||
and populates ftp.list_result. Returns the list of DirectoryEntry.
|
||||
"""
|
||||
ftp.cmd_list([path])
|
||||
return list(ftp.list_result)
|
||||
|
||||
|
||||
def ftp_download(mav, ftp, remote, local, report):
|
||||
"""Download one remote file to a local path over MAVFTP, bounded by
|
||||
FTP_BUDGET and reporting a stall by byte count.
|
||||
|
||||
cmd_get only sends OpenFileRO and returns; the transfer itself is driven
|
||||
by process_ftp_reply, which loops recv_match -> __mavlink_packet (burst
|
||||
read handling, temp-file writes) -> __idle_task until idle or timeout.
|
||||
We use a completion callback so we know the burst read finished, and we
|
||||
pump process_ftp_reply in bounded slices so a stalled burst becomes a
|
||||
named FAIL instead of a hang. process_ftp_reply requires its timeout to
|
||||
be greater than idle_detection_time (default 3.7s).
|
||||
|
||||
Returns (elapsed_seconds, None) on success, (None, error_string) on
|
||||
failure, so the caller can branch on the error explicitly instead of
|
||||
ever mixing a message string into arithmetic.
|
||||
"""
|
||||
done = {'ok': False}
|
||||
|
||||
def on_complete(fh):
|
||||
# fh is a BytesIO on success, or None if the session was terminated
|
||||
# (failure). cmd_get with a callback keeps the payload in memory, so we
|
||||
# persist it here ourselves.
|
||||
if fh is None:
|
||||
return
|
||||
try:
|
||||
fh.seek(0)
|
||||
with open(local, 'wb') as out:
|
||||
out.write(fh.read())
|
||||
done['ok'] = True
|
||||
except Exception as e: # noqa: BLE001 - surface write failures to the caller
|
||||
report.info('local write failed: {}'.format(e))
|
||||
|
||||
ftp.cmd_get([remote], callback=on_complete)
|
||||
|
||||
start = time.monotonic()
|
||||
last_bytes = 0
|
||||
last_progress = start
|
||||
next_hb = 0.0
|
||||
# process_ftp_reply timeout must exceed idle_detection_time; use a short
|
||||
# slice and loop so we can enforce our own budget and stall detection.
|
||||
slice_timeout = max(4.0, ftp.ftp_settings.idle_detection_time + 0.5)
|
||||
while not done['ok']:
|
||||
now = time.monotonic()
|
||||
if now > next_hb:
|
||||
send_heartbeat(mav)
|
||||
next_hb = now + 1.0
|
||||
if now - start > FTP_BUDGET:
|
||||
cur = ftp.read_total if ftp.read_total else last_bytes
|
||||
return None, 'MAVFTP transfer stalled at {} bytes (budget {:.0f}s)'.format(
|
||||
cur, FTP_BUDGET)
|
||||
# drive one slice of the FTP state machine
|
||||
ftp.process_ftp_reply('OpenFileRO', timeout=slice_timeout)
|
||||
cur = ftp.read_total
|
||||
if cur > last_bytes:
|
||||
last_bytes = cur
|
||||
last_progress = now
|
||||
elif now - last_progress > 30.0 and not done['ok']:
|
||||
# no forward progress for 30s and callback never fired
|
||||
return None, 'MAVFTP transfer stalled at {} bytes (no progress 30s)'.format(cur)
|
||||
# if the session ended without completing, process_ftp_reply keeps
|
||||
# returning immediately on idle; break out via the budget/stall checks
|
||||
elapsed = time.monotonic() - start
|
||||
return elapsed, None
|
||||
Executable → Regular
+9
-149
@@ -1,18 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mission upload/download/clear torture test for on-bench PX4 hardware.
|
||||
|
||||
v1.18 risk area: dataman and the mission shared-state path were reworked and
|
||||
a brand-new mutex now guards mission shared state. A regression there shows
|
||||
up as a SILENT HANG (a MISSION_REQUEST that never arrives, a MISSION_ACK that
|
||||
never comes, a download that stalls mid-list) or as a corrupted round-trip.
|
||||
This script repeatedly uploads a large mission, reads it back and compares it
|
||||
item-by-item, then clears it, with a hard timeout on every wait so a stall
|
||||
becomes a named FAIL naming the stalled step and the seq reached.
|
||||
|
||||
With a second connection given, iterations alternate between the two links
|
||||
(iteration parity picks the link) to exercise the new mission shared-state
|
||||
mutex from two MAVLink channels.
|
||||
"""Mission protocol helpers: item generation, upload/download/compare/clear.
|
||||
|
||||
Mission protocol (verified in src/modules/mavlink/mavlink_mission.cpp):
|
||||
upload: MISSION_COUNT -> per-item MISSION_REQUEST_INT / MISSION_REQUEST
|
||||
@@ -21,25 +7,15 @@ Mission protocol (verified in src/modules/mavlink/mavlink_mission.cpp):
|
||||
MISSION_REQUEST_INT -> we reply, then send MISSION_ACK(ACCEPTED)
|
||||
clear: MISSION_CLEAR_ALL -> MISSION_ACK(ACCEPTED)
|
||||
|
||||
Usage:
|
||||
mission_torture.py CONNECTION [CONNECTION2] [-b BAUD]
|
||||
[--baudrate2 BAUD2] [--iterations N] [--items K]
|
||||
The autopilot may request items with either MISSION_REQUEST_INT or the
|
||||
deprecated MISSION_REQUEST; both are answered with MISSION_ITEM_INT, and
|
||||
duplicate or out-of-order requests are answered as asked.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import px4bench
|
||||
from px4bench import mavutil
|
||||
|
||||
DEFAULT_ITERATIONS = 10
|
||||
DEFAULT_ITEMS = 220 # <= smallest CONFIG_NUM_MISSION_ITMES_SUPPORTED (500)
|
||||
DEFAULT_BAUD2 = 57600
|
||||
from pymavlink import mavutil
|
||||
|
||||
MAV_CMD_NAV_WAYPOINT = mavutil.mavlink.MAV_CMD_NAV_WAYPOINT
|
||||
MAV_FRAME = mavutil.mavlink.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
|
||||
@@ -49,11 +25,14 @@ MAV_MISSION_ACCEPTED = mavutil.mavlink.MAV_MISSION_ACCEPTED
|
||||
BASE_LAT = 47.397742
|
||||
BASE_LON = 8.545594
|
||||
|
||||
# Per-message wait cap and per-iteration transaction deadline.
|
||||
STEP_TIMEOUT_S = 5.0
|
||||
TRANSACTION_DEADLINE_S = 60.0
|
||||
COUNT_RETRANSMITS = 3
|
||||
|
||||
UPLOAD_TYPES = ['MISSION_REQUEST_INT', 'MISSION_REQUEST', 'MISSION_ACK']
|
||||
DOWNLOAD_COUNT_TYPES = ['MISSION_COUNT', 'MISSION_ACK']
|
||||
DOWNLOAD_ITEM_TYPES = ['MISSION_ITEM_INT', 'MISSION_ITEM', 'MISSION_ACK']
|
||||
|
||||
|
||||
def transaction_deadline(num_items):
|
||||
"""Transaction budget scaled for large missions on slow links.
|
||||
@@ -64,15 +43,6 @@ def transaction_deadline(num_items):
|
||||
"""
|
||||
return max(TRANSACTION_DEADLINE_S, 0.5 * num_items)
|
||||
|
||||
# Messages we care about; anything else (PARAM_VALUE, HEARTBEAT, ...) is drained.
|
||||
UPLOAD_TYPES = ['MISSION_REQUEST_INT', 'MISSION_REQUEST', 'MISSION_ACK']
|
||||
DOWNLOAD_COUNT_TYPES = ['MISSION_COUNT', 'MISSION_ACK']
|
||||
DOWNLOAD_ITEM_TYPES = ['MISSION_ITEM_INT', 'MISSION_ITEM', 'MISSION_ACK']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deterministic mission generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Item:
|
||||
"""A single generated waypoint; comparison uses the fields we round-trip."""
|
||||
@@ -103,10 +73,6 @@ def generate_mission(iteration, k):
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def send_item(mav, item):
|
||||
mav.mav.mission_item_int_send(
|
||||
mav.target_system, mav.target_component,
|
||||
@@ -170,10 +136,6 @@ def upload_mission(report, mav, items, iteration):
|
||||
transaction_deadline(len(items)), seq_reached))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download + compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def download_mission(mav):
|
||||
"""Request and collect the mission. Returns (items_or_None, duration, detail)."""
|
||||
start = time.monotonic()
|
||||
@@ -182,7 +144,6 @@ def download_mission(mav):
|
||||
mav.mav.mission_request_list_send(
|
||||
mav.target_system, mav.target_component, MAV_MISSION_TYPE_MISSION)
|
||||
|
||||
# Wait for MISSION_COUNT.
|
||||
count = None
|
||||
while time.monotonic() < deadline:
|
||||
m = mav.recv_match(type=DOWNLOAD_COUNT_TYPES, blocking=True, timeout=STEP_TIMEOUT_S)
|
||||
@@ -255,10 +216,6 @@ def compare_mission(expected, downloaded):
|
||||
return True, 'all {} items match'.format(len(expected))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clear_mission(mav):
|
||||
"""MISSION_CLEAR_ALL -> MISSION_ACK(ACCEPTED). Returns (ok, detail)."""
|
||||
deadline = time.monotonic() + TRANSACTION_DEADLINE_S
|
||||
@@ -298,100 +255,3 @@ def verify_cleared(mav):
|
||||
return True, 'empty mission (ACK) after clear'
|
||||
return False, 'verify-clear got MISSION_ACK type {}'.format(m.type)
|
||||
return False, 'verify-clear transaction deadline hit'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_iteration(report, mav, iteration, items_k, link_label):
|
||||
"""One full upload/download/compare/clear cycle. Returns True on full pass."""
|
||||
expected = generate_mission(iteration, items_k)
|
||||
|
||||
up_ok, up_dur, up_detail = upload_mission(report, mav, expected, iteration)
|
||||
if not report.check('iter{}_upload'.format(iteration), up_ok,
|
||||
'[{}] {} ({:.1f}s)'.format(link_label, up_detail, up_dur)):
|
||||
return False
|
||||
|
||||
downloaded, dl_dur, dl_detail = download_mission(mav)
|
||||
if downloaded is None:
|
||||
report.fail('iter{}_download'.format(iteration),
|
||||
'[{}] {} ({:.1f}s)'.format(link_label, dl_detail, dl_dur))
|
||||
return False
|
||||
|
||||
cmp_ok, cmp_detail = compare_mission(expected, downloaded)
|
||||
if not report.check('iter{}_compare'.format(iteration), cmp_ok,
|
||||
'[{}] {}'.format(link_label, cmp_detail)):
|
||||
return False
|
||||
|
||||
clr_ok, clr_detail = clear_mission(mav)
|
||||
if not report.check('iter{}_clear'.format(iteration), clr_ok,
|
||||
'[{}] {}'.format(link_label, clr_detail)):
|
||||
return False
|
||||
|
||||
vfy_ok, vfy_detail = verify_cleared(mav)
|
||||
report.check('iter{}_verify_clear'.format(iteration), vfy_ok,
|
||||
'[{}] {}'.format(link_label, vfy_detail))
|
||||
|
||||
if up_ok and downloaded is not None and cmp_ok and clr_ok and vfy_ok:
|
||||
report.info('iter {} PASS [{}] upload {:.1f}s download {:.1f}s'.format(
|
||||
iteration, link_label, up_dur, dl_dur))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
px4bench.add_connection_args(parser, dual_link=True)
|
||||
parser.add_argument('--baudrate2', type=int, default=DEFAULT_BAUD2,
|
||||
help='baud rate for the second connection (default: %(default)s)')
|
||||
parser.add_argument('--iterations', type=int, default=DEFAULT_ITERATIONS,
|
||||
help='upload/download/clear iterations (default: %(default)s)')
|
||||
parser.add_argument('--items', type=int, default=DEFAULT_ITEMS,
|
||||
help='mission items per iteration (default: %(default)s)')
|
||||
args = parser.parse_args()
|
||||
|
||||
report = px4bench.Reporter('mission_torture')
|
||||
|
||||
try:
|
||||
mav1 = px4bench.connect(args.connection, baud=args.baudrate,
|
||||
timeout=args.connect_timeout)
|
||||
except (TimeoutError, OSError) as e:
|
||||
report.fail('connect', 'link 1 {}: {}'.format(args.connection, e))
|
||||
sys.exit(report.finish())
|
||||
report.info('link 1 connected to system {} component {}'.format(
|
||||
mav1.target_system, mav1.target_component))
|
||||
|
||||
mav2 = None
|
||||
if args.connection2:
|
||||
try:
|
||||
mav2 = px4bench.connect(args.connection2, baud=args.baudrate2,
|
||||
timeout=args.connect_timeout)
|
||||
report.info('link 2 connected to system {} component {}'.format(
|
||||
mav2.target_system, mav2.target_component))
|
||||
except (TimeoutError, OSError) as e:
|
||||
report.fail('connect', 'link 2 {}: {}'.format(args.connection2, e))
|
||||
sys.exit(report.finish())
|
||||
|
||||
try:
|
||||
for iteration in range(args.iterations):
|
||||
if mav2 is not None and (iteration % 2 == 1):
|
||||
mav, label = mav2, 'link2'
|
||||
else:
|
||||
mav, label = mav1, 'link1'
|
||||
run_iteration(report, mav, iteration, args.items, label)
|
||||
finally:
|
||||
for m in (mav1, mav2):
|
||||
try:
|
||||
if m is not None:
|
||||
m.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(report.finish())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Parameter protocol helpers: PX4 int32 union encoding, read/set/echo.
|
||||
|
||||
PX4 transports an INT32 parameter as the raw bit pattern of the int placed
|
||||
into the float param_value field. To send: pack the int as '<i', reinterpret
|
||||
those 4 bytes as '<f'. To receive: pack the received float as '<f',
|
||||
reinterpret as '<i'. This mirrors the union trick the firmware uses; the
|
||||
param float is never a numeric conversion of the int.
|
||||
|
||||
Echo handling is hardware-learned: PX4 can emit more than one PARAM_VALUE
|
||||
per set (handler reply plus the changed-param announcement, times the number
|
||||
of mavlink instances), so callers must drain stale PARAM_VALUE messages
|
||||
before a set and then match the echo by expected value, never consume it
|
||||
positionally.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import time
|
||||
|
||||
from pymavlink import mavutil
|
||||
|
||||
MAV_PARAM_TYPE_INT32 = mavutil.mavlink.MAV_PARAM_TYPE_INT32
|
||||
|
||||
SET_ECHO_TIMEOUT_S = 5.0
|
||||
READ_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def int32_to_param_float(value):
|
||||
"""Reinterpret an int32 bit pattern as the float carried in param_value."""
|
||||
return struct.unpack('<f', struct.pack('<i', int(value)))[0]
|
||||
|
||||
|
||||
def param_float_to_int32(value):
|
||||
"""Reinterpret a param_value float's bit pattern as an int32."""
|
||||
return struct.unpack('<i', struct.pack('<f', float(value)))[0]
|
||||
|
||||
|
||||
def param_id_str(raw):
|
||||
"""Normalize a PARAM_VALUE.param_id (bytes or str) to a plain string."""
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode('ascii', errors='replace')
|
||||
return raw.rstrip('\x00')
|
||||
|
||||
|
||||
def request_param_read(mav, name):
|
||||
"""Send PARAM_REQUEST_READ by name (param_index = -1)."""
|
||||
mav.mav.param_request_read_send(
|
||||
mav.target_system, mav.target_component,
|
||||
name.encode('ascii'), -1)
|
||||
|
||||
|
||||
def read_param(mav, name, timeout=READ_TIMEOUT_S):
|
||||
"""PARAM_REQUEST_READ then wait for the matching PARAM_VALUE.
|
||||
|
||||
Returns (int32_value, raw_float) or (None, None) on timeout.
|
||||
"""
|
||||
request_param_read(mav, name)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
m = mav.recv_match(type='PARAM_VALUE', blocking=True,
|
||||
timeout=max(0.1, deadline - time.monotonic()))
|
||||
if m is None:
|
||||
continue
|
||||
if param_id_str(m.param_id) == name:
|
||||
return param_float_to_int32(m.param_value), m.param_value
|
||||
return None, None
|
||||
|
||||
|
||||
def set_param_int32(mav, name, value):
|
||||
"""PARAM_SET an int32 using the union encoding."""
|
||||
mav.mav.param_set_send(
|
||||
mav.target_system, mav.target_component,
|
||||
name.encode('ascii'),
|
||||
int32_to_param_float(value),
|
||||
MAV_PARAM_TYPE_INT32)
|
||||
|
||||
|
||||
def drain_param_values(mav):
|
||||
"""Discard any queued PARAM_VALUE messages (stale echoes/broadcasts)."""
|
||||
while mav.recv_match(type='PARAM_VALUE', blocking=False) is not None:
|
||||
pass
|
||||
|
||||
|
||||
def wait_param_echo(mav, name, expected, timeout=SET_ECHO_TIMEOUT_S):
|
||||
"""Wait for a PARAM_VALUE for name carrying the expected int32 value.
|
||||
|
||||
PX4 can emit more than one PARAM_VALUE per set (handler reply plus the
|
||||
changed-param announcement, times the number of mavlink instances), so
|
||||
consuming a single message desyncs the harness by one echo forever.
|
||||
Returns (matched, seen) where seen is every value observed for name; a
|
||||
genuine wrong-echo firmware bug shows up as matched=False with the wrong
|
||||
value(s) in seen.
|
||||
"""
|
||||
seen = []
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
m = mav.recv_match(type='PARAM_VALUE', blocking=True,
|
||||
timeout=max(0.1, deadline - time.monotonic()))
|
||||
if m is None:
|
||||
continue
|
||||
if param_id_str(m.param_id) != name:
|
||||
continue
|
||||
value = param_float_to_int32(m.param_value)
|
||||
seen.append(value)
|
||||
if value == expected:
|
||||
return True, seen
|
||||
return False, seen
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "px4bench"
|
||||
version = "0.1.0"
|
||||
description = "PX4 bench-test suite: hang-visible release qualification on real NuttX hardware"
|
||||
readme = "README.md"
|
||||
license = { text = "BSD-3-Clause" }
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"pymavlink>=2.4.40",
|
||||
"pyserial",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ulog = ["pyulog"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/PX4/PX4-Autopilot"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["px4bench"]
|
||||
@@ -22,22 +22,22 @@ import sys
|
||||
import time
|
||||
|
||||
|
||||
# Test order for the suite. dual_link_forwarding is inserted after
|
||||
# mission_torture only when a second connection is provided.
|
||||
# Test order for the suite. link_forwarding is inserted after
|
||||
# mission_stress only when a second connection is provided.
|
||||
BASE_SEQUENCE = [
|
||||
'boot_health',
|
||||
'param_torture',
|
||||
'mission_torture',
|
||||
'mavftp_log',
|
||||
'param_stress',
|
||||
'mission_stress',
|
||||
'log_transfer',
|
||||
'reboot_loop',
|
||||
]
|
||||
DUAL_LINK_TEST = 'dual_link_forwarding'
|
||||
DUAL_LINK_AFTER = 'mission_torture'
|
||||
DUAL_LINK_TEST = 'link_forwarding'
|
||||
DUAL_LINK_AFTER = 'mission_stress'
|
||||
|
||||
|
||||
def script_path(name):
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(here, name + '.py')
|
||||
return os.path.join(here, 'bench', name + '.py')
|
||||
|
||||
|
||||
def build_argv(name, args):
|
||||
@@ -45,7 +45,7 @@ def build_argv(name, args):
|
||||
argv = [sys.executable, script_path(name), args.connection]
|
||||
|
||||
# tests that take a second positional connection
|
||||
if name in ('mission_torture', DUAL_LINK_TEST):
|
||||
if name in ('mission_stress', DUAL_LINK_TEST):
|
||||
if args.connection2:
|
||||
argv.append(args.connection2)
|
||||
|
||||
@@ -53,10 +53,8 @@ def build_argv(name, args):
|
||||
argv += ['-b', str(args.baudrate)]
|
||||
|
||||
# per-test extra flags
|
||||
if name == 'boot_health':
|
||||
if name in ('boot_health', 'log_transfer'):
|
||||
argv += ['--report-dir', args.report_dir]
|
||||
elif name == 'mavftp_log':
|
||||
argv += ['--outdir', args.report_dir]
|
||||
|
||||
return argv
|
||||
|
||||
@@ -113,8 +111,8 @@ def main():
|
||||
parser.add_argument('connection',
|
||||
help='MAVLink connection: serial device, udp:IP:PORT, or tcp:IP:PORT')
|
||||
parser.add_argument('connection2', nargs='?', default=None,
|
||||
help='optional second connection; enables dual_link_forwarding '
|
||||
'and is passed to mission_torture')
|
||||
help='optional second connection; enables link_forwarding '
|
||||
'and is passed to mission_stress')
|
||||
parser.add_argument('--baudrate', '-b', type=int, default=57600,
|
||||
help='serial baud rate (default: %(default)s)')
|
||||
parser.add_argument('--skip', default='',
|
||||
@@ -143,7 +141,9 @@ def main():
|
||||
if skip:
|
||||
print(' skipping : {}'.format(', '.join(sorted(skip))))
|
||||
print(' NOTE: usb_replug is interactive and is not run by this suite; '
|
||||
'run it manually.')
|
||||
'run it manually (bench/usb_replug.py).')
|
||||
print(' NOTE: the SIH simulated flight (sih/flight_mission.py) reconfigures '
|
||||
'the board and is run separately.')
|
||||
print()
|
||||
|
||||
results = [] # (name, status, duration, detail)
|
||||
|
||||
@@ -22,17 +22,16 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import px4bench
|
||||
from px4bench import Reporter, MavlinkShell, add_connection_args, connect
|
||||
|
||||
from param_torture import (read_param, set_param_int32, wait_param_echo,
|
||||
drain_param_values)
|
||||
import mission_torture
|
||||
from mission_torture import Item, BASE_LAT, BASE_LON
|
||||
import mavftp_log
|
||||
from mavftp_log import LOG_ROOT, ULOG_MAGIC7
|
||||
from px4bench import ftp as bench_ftp
|
||||
from px4bench import missions
|
||||
from px4bench.ftp import LOG_ROOT, ULOG_MAGIC7
|
||||
from px4bench.missions import BASE_LAT, BASE_LON, Item
|
||||
from px4bench.params import (drain_param_values, read_param, set_param_int32,
|
||||
wait_param_echo)
|
||||
|
||||
from pymavlink import mavutil
|
||||
|
||||
@@ -52,7 +51,7 @@ WP_OFFSET_DEG = 0.0005 # ~55 m legs
|
||||
|
||||
|
||||
def build_flight_mission(alt):
|
||||
"""Takeoff, 3-waypoint square leg, RTL. Reuses mission_torture's Item."""
|
||||
"""Takeoff, 3-waypoint square leg, RTL. Reuses px4bench.missions.Item."""
|
||||
home_lat = int(BASE_LAT * 1e7)
|
||||
home_lon = int(BASE_LON * 1e7)
|
||||
off = int(WP_OFFSET_DEG * 1e7)
|
||||
@@ -207,13 +206,13 @@ def fly(report, mav, shell, alt, report_dir):
|
||||
# matches the stored mission CRC and PX4 keeps the completed progress
|
||||
# (mission-resume semantics), so the vehicle arms into finished=true
|
||||
# and never takes off.
|
||||
ok, detail = mission_torture.clear_mission(mav)
|
||||
ok, detail = missions.clear_mission(mav)
|
||||
report.check('mission_clear', ok, detail)
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
items = build_flight_mission(alt)
|
||||
ok, duration, detail = mission_torture.upload_mission(report, mav, items, 0)
|
||||
ok, duration, detail = missions.upload_mission(report, mav, items, 0)
|
||||
report.check('mission_upload', ok, detail or 'uploaded in {:.1f}s'.format(duration))
|
||||
if not ok:
|
||||
return False
|
||||
@@ -315,20 +314,20 @@ def download_flight_log(report, mav, report_dir):
|
||||
try:
|
||||
ftp = mavftp.MAVFTP(mav, target_system=mav.target_system,
|
||||
target_component=1)
|
||||
dirs = [e.name for e in mavftp_log.ftp_list(ftp, LOG_ROOT)
|
||||
dirs = [e.name for e in bench_ftp.ftp_list(ftp, LOG_ROOT)
|
||||
if e.is_dir and not e.name.startswith('.')]
|
||||
if not dirs:
|
||||
report.fail('flight_log', 'no log directories on SD')
|
||||
return
|
||||
log_dir = '{}/{}'.format(LOG_ROOT, sorted(dirs)[-1])
|
||||
ulogs = sorted(e.name for e in mavftp_log.ftp_list(ftp, log_dir)
|
||||
ulogs = sorted(e.name for e in bench_ftp.ftp_list(ftp, log_dir)
|
||||
if e.name.endswith('.ulg'))
|
||||
if not ulogs:
|
||||
report.fail('flight_log', 'no .ulg in {}'.format(log_dir))
|
||||
return
|
||||
remote = '{}/{}'.format(log_dir, ulogs[-1])
|
||||
local = os.path.join(report_dir, ulogs[-1])
|
||||
elapsed, err = mavftp_log.ftp_download(mav, ftp, remote, local, report)
|
||||
elapsed, err = bench_ftp.ftp_download(mav, ftp, remote, local, report)
|
||||
if err is not None:
|
||||
report.fail('flight_log', 'download failed: {}'.format(err))
|
||||
return
|
||||
@@ -363,8 +362,8 @@ def main():
|
||||
parser.add_argument('--report-dir', default='bench_reports')
|
||||
args = parser.parse_args()
|
||||
|
||||
report = Reporter('sih_flight')
|
||||
report_dir = px4bench.make_report_dir(args.report_dir, 'sih_flight')
|
||||
report = Reporter('flight_mission')
|
||||
report_dir = px4bench.make_report_dir(args.report_dir, 'flight_mission')
|
||||
report.info('report dir: {}'.format(report_dir))
|
||||
|
||||
try:
|
||||
Reference in New Issue
Block a user