Testing
How to run every test of RadioFoundry, from the host tests to the radio on the SDR bench. Each tier is repeatable and skips cleanly what it can't reach. What each level is for and where a new test goes: docs/dev/testing.md.
| Tier | Needs | Command | Where it is described |
|---|---|---|---|
| Release gate (host tests, build matrix, license checks) | a Mac or Linux host with the ARM toolchain | tools/ci.sh |
this page, section 1 |
| Coverage, sanitizers, fuzzing | the host (gcovr for coverage) | tools/coverage.sh, make -C tests/host SANITIZE=1 |
docs/dev/ci.md |
| Hardware in the loop, radio only | the radio on USB | python3 -m pytest tests/hil -v -rs |
tests/hil/README.md |
| Hardware in the loop, on air | the radio and the SDR bench | the same with opt-in flags | tests/hil/README.md, section 3 here |
| Manual bench cases | the SDR bench, a person at the radio | tools/bench/dmr_bench.py run CASE |
docs/testing/dmr-bench.md |
| DM-32UV first hardware test | a DM-32UV on its programming cable, a dummy load | step by step | docs/testing/dm32-bringup.md |
| MD-380 first hardware test | an MD-380 / MD-390 / RT3 on USB, a dummy load, an SDR or signal generator | step by step | docs/testing/md380-bringup.md |
1. The release gate: tools/ci.sh
tools/ci.sh # everything below
tools/ci.sh --quick # each board's default build only (no feature matrix)
tools/ci.sh --board <b> # one board, for example dm1701
tools/ci.sh --matrix # only the feature matrix (no default builds)
tools/ci.sh --steps host,python # only some steps: host, python, firmware, licenses, similarity
JOBS=8 tools/ci.sh # parallel make jobs (default: the CPU count)
RFOUNDRY_CI_PRUNE=1 tools/ci.sh # drop each build tree once its size is recorded (logs and images stay)
RFOUNDRY_CI_SANITIZE=1 tools/ci.sh # the host step also runs the host tests under ASan and UBSan
The CI workflows run these pieces per push and nightly (docs/dev/ci.md).
It runs, and reports each as ok / FAIL / skip (exit status 0 only when nothing failed):
-
Host tests:
make -C tests/host(the same asmake test), built with no warnings. They need only the host's C compiler. Their binaries,.dSYMbundles and outputs stay intests/host/and are ignored by git (tests/host/.gitignoretracks only sources);make -C tests/host cleanremoves them. -
Python: every tool and HIL test parses, and pytest collects
tests/hil(skipped without pytest:pip install pytest pyserial). Every HIL skip names its kind (tools/check_hil_skips.py), and the feature checklist is consistent (tools/bringup.py checkand the bring-up mode tests, ci.md). The flashing tools' tests run:tools/rfoundry/tests(sector planning, manifests, the codec library and key derivation, the codec decision, whole flashes against a fake bootloader with a flash model; release.md) and the--self-testoftools/codec_extract.py,tools/stm32_dfu.py,tools/md380_migrate.py,tools/serial_loader.py(against a simulated Baofeng bootloader) andtools/dm32_migrate.py(DfuSe files, the conversion report, CPS writes against a simulated DM-32 shim), and the stack check's (tools/stack_check.py).tools/check_doc_links.pychecks that every relative link, image and anchor in the Markdown resolves. -
Firmware, every board with the feature matrix. For each
boards/<b>/board.mkwhose compiler is installed:default: the board's defaults;no-<FEATURE>: every switch that is on by default, turned off alone (its sub-features follow it,docs/build-config.md);fm-minimal: FM only, without DMR, USB, VFO, diagnostics and sub-audio tones;maximal: every switch on, except the board'sBOARD_UNSUPPORTED,BOARD_NOT_WRITTENandMAXIMAL_SKIP, with itsMAXIMAL_VARS(for exampleLTO=1on the DM-1701);vhf: the MD-380's VHF image (MD-380 only).
Every configuration must build with zero compiler and linker warnings. The default and maximal ones must also pass the stack check: every task's deepest call chain fits its stack (docs/dev/stack-check.md). A board without its compiler (the DM-32's csky GCC comes from
tools/build-csky-toolchain.shor the Docker image, docs/dev/ci-runners.md) is skipped with a notice. Build trees arebuild/ci/<board>-<config>/, each with its log next to it. The sizes go tobuild/ci/size-report.md: image bytes, the change against the board's default, and the space left in the linker's application region (the codec windows lie outside it, so "free" is what code can still grow into). -
Licenses:
tools/license_grep.sh(markers of code that must not be here,docs/CLEANROOM.md) andreuse lint. Withoutreuseinstalled the lint is skipped with a notice; install it withpipx install reuse(orpip install --user reuse). -
Similarity scan, only when
RFOUNDRY_SIMILARITY_REFSnames the reference trees (space separated). The reference trees are never part of this repository or of public CI; the project lead runs it against the aftermarket firmware trees before a release:RFOUNDRY_SIMILARITY_REFS="/path/to/aftermarket-tree /path/to/other-tree" tools/ci.sh tools/similarity_scan.py /path/to/aftermarket-tree # the scan alone, every region with lines tools/similarity_scan.py --ids --report scan.txt /path/to/tree # also finds copies with renamed identifiersEach reported region gives our file and lines and the reference file and line it shares a run of 25 tokens with. Every region is reviewed by hand;
#includeblocks and generic idioms are expected noise. Options:tools/similarity_scan.py --help.
The firmware version
The image carries the commit it was built from (RFOUNDRY_GIT, shown on the splash screen, in the debug link's VERSION
and in the MMDVM GET_VERSION description) with -dirty appended when tracked files differ from that commit or untracked
sources sit in the built directories. The build prints it on the image line (image ...: N bytes, version c1544f29-dirty). An image for anyone else is built from a clean, committed tree.
The UI without a radio
The UI's host tests (make -C tests/host run-ui, part of the host tests above) replay every task flow of
docs/ux/task-flows.md against the press budgets of docs/ux/usability-tests.md on four display and key map pairs,
and crawl every screen for the rules that hold everywhere (docs/spec/ui.md 7). make -C tests/host ui-gallery
renders every screen at every display class into docs/ux/screens/; run it after a UI change and commit the PNGs
with it, so the review shows what changed. tests/host/uiSim renders one screen after a key script, to PNG.
2. Hardware in the loop, radio only
python3 -m pytest tests/hil -v -rs
The radio on USB with firmware built with CONFIG_DEBUGLINK (the default). Nothing transmits: version and features,
screenshots, injected keys, FM receive on NOAA weather, MMDVM requests. Without a radio every test is skipped with
the reason, which starts with the skip's kind ([bench]). Every test file, rig assumptions and pass criteria:
tests/hil/README.md. A radio's feature checklist run is tools/bringup.py run --radio <id> (its "Bring-up mode").
The USB network tests (test_usb_network*.py, test_tcp_services.py, test_net_services.py, test_snmp.py) switch
the radio to the network personality and back, so they need --allow-usb-switch. On macOS a missed NCM carrier can
leave the radio a network adapter until it is power cycled (docs/dev/ci-runners.md, "Gotchas").
CPS and settings (test_cps.py): the radio-only tests read the codeplug twice over the open-firmware CPS protocol and
compare, and check a channel change is saved. Two opt-in flags change the radio but don't transmit:
python3 -m pytest tests/hil -v -rs -s -k cps --allow-codeplug-write # backs up, renames a channel, restores
python3 -m pytest tests/hil -v -rs -s -k cps --allow-reboot # channel position survives a restart
The write test saves the backup in its tmp directory and prints the path. If the restore fails, the path and the
tools/cps_client.py write command are in the failure message.
The UI controls (test_ui.py): the Quick tiles drive the radio core with injected keys and read it back (timeslot,
color code and mode for now, hear all, monitor latch, VOX), the picker hands its focus row to the radio core, Settings
reach the input layer (long press time) and the UI (auto lock, about 35 s). The DMR tests need a DMR channel, the
monitor and VOX tests an FM channel; the others skip. Three change the radio and are opt-in:
python3 -m pytest tests/hil/test_ui.py -v -rs -k save --allow-codeplug-write # timeslot saved to the channel and back
RFOUNDRY_HIL_DELETE_MESSAGE=1 python3 -m pytest tests/hil/test_ui.py -v -rs -k delete # deletes the newest inbox message
RFOUNDRY_HIL_FACTORY_RESET=1 python3 -m pytest tests/hil/test_ui.py -v -rs -k factory --allow-reboot # erases all settings
Factory reset puts every setting back to its default (brightness, privacy keys, radio settings, the channel position) and erases the native area; the codeplug and calibration stay. Note the settings before running it.
3. Hardware in the loop, on air (opt-in)
Each flag allows one more kind of action, and a test runs only when all it needs is allowed:
| Flag | Allows |
|---|---|
--allow-rf |
anything that transmits: the SDR bench's modem, the radio itself |
--allow-service-stop |
stopping the SDR bench's gateway service (always restarted by a bench-side guard) to use its modem and RTL-SDR |
--allow-usb-switch, --allow-codeplug-write, --allow-reboot |
as in section 2 (no RF) |
python3 -m pytest tests/hil -v -rs -s --allow-rf --ambe SPEECH.ambe --dmr-freq MHZ # the default bench pass, DMR audio smoke included
python3 -m pytest tests/hil -v -rs -s --allow-rf --allow-service-stop # DMR RX / voice, DMR TX, FM TX
python3 -m pytest tests/hil -v -rs -s --allow-rf --allow-service-stop -k "dmr_tx or fm_tx"
python3 -m pytest tests/hil -v -rs -s --allow-rf --allow-service-stop -k dmr_data # DMR data services
python3 -m pytest tests/hil -v -rs -s --allow-rf --allow-service-stop -k aprs --aprs-call CALL # APRS, 446.050 MHz
RFOUNDRY_SPLIT_VARIANT=0 python3 -m pytest tests/hil/test_split_duplex.py -v -rs -s --allow-rf --allow-service-stop
| Test | What is checked |
|---|---|
test_dmr_rx, test_dmr_voice |
the bench modem sends calls; the radio's counters show every burst, every voice frame reaches the speaker path |
test_dmr_audio_smoke |
DMR audio both ways, which no counter shows: the bench's speech decodes to audio on the radio, frames not repeated; the radio's test signal reaches the air as distinct, non-silent frames that decode to audio when played back. Part of every bench pass on every radio with DMR (tests/hil/README.md, Default bench pass); a run without it prints DMR AUDIO NOT CHECKED |
test_dmr_tx |
the radio keys 3 s; an SDR capture (sdrmon.py) and the bench modem's listener check source ID, talkgroup, color code, headers / voice / terminator, slot timing, ramps, carrier offset |
test_fm_tx |
the radio's FM VFO keys with CTCSS 100.0 Hz, DCS D023N, at 12.5 and 25 kHz, the 1750 Hz burst, the roger beep, three power levels; tools/sdr_fm_check.py decodes the tones (--tones: audio tones) and compares the deviations |
test_aprs |
APRS at the lowest power on a 70 cm simplex frequency (446.050 MHz by default, never an APRS frequency, no path): position, status and message decoded off air by tools/aprs_afsk_check.py (Bell 202 / AX.25 from the RTL-SDR's I/Q), with the deviation, twist, phase continuity and TX delay; positions over DMR (LRRP, UDT) decoded from the bench modem |
test_dmr_data |
the bench sends data, a text message, a call alert and radio checks to the radio, which answers (its dmr_data counters, the answers heard by the bench modem); the radio sends a call alert, a radio check and a text message the bench modem hears (docs/spec/dmr-data.md) |
test_split_duplex |
a CONFIG_HOTSPOT_SPLIT=y build: the 0xD6 protocol (no RF); the TX half's continuous BS outbound at the lowest power checked off air with the CACH decode of the BS framing experiment (variant from RFOUNDRY_SPLIT_VARIANT); the RX half labeling bench calls, the label flipping with the epoch (docs/spec/split-duplex.md 10) |
test_hotspot_voice |
the hotspot on air both ways: bench calls and data up the USB link, host calls and data heard on air |
test_radio_features (rf) |
the zone scan, VFO scan and dual watch holding on a bench call; battery save waking for it |
Which feature is covered on air, and what the last run found: on-air-coverage.md. On this
bench the radio overloads both receivers even at 50 mW; tests/hil/README.md ("On this bench") says what the tests
do about it.
The SDR bench's names come from the environment: RFOUNDRY_SDR_BENCH_HOST (ssh host or alias, default sdr-bench),
RFOUNDRY_SDR_BENCH_SERVICE, RFOUNDRY_SDR_BENCH_DIR, RFOUNDRY_SDR_BENCH_PY, RFOUNDRY_SDR_BENCH_ENV_FILE
(docs/testing/dmr-bench.md section 1).
4. Manual bench cases
docs/testing/dmr-bench.md: the DMR test cases that need a person at the radio (talking, reading the display), run
end to end by tools/bench/dmr_bench.py with automatic checks where possible; --dry-run prints every command
first. docs/testing/mmdvm-audit.md: the MMDVM protocol audit of the hotspot. docs/testing/dm32-bringup.md: the
DM-32UV port's first test on a radio, in order, with what to change when a step fails; docs/testing/md380-bringup.md
is the same for the MD-380.
The split duplex repeater has no hardware-free gap: make -C tests/host runs splitDuplexTest (the CACH and short
LC against the standard's tables, the outbound buffer, the epoch, both roles through the MMDVM core),
hrc6000BsTest (the HR_C6000 BS outbound engine on a fake chip) and sim/splitPairTest.py (two radios' cores in
lockstep virtual time with drift, a clock wrap and USB jitter, a repeated call end to end); the bench steps are
docs/spec/split-duplex.md 10.