Skip to content

The automation server

A gRPC server on the board, on port 7020, and a Python client for your PC: status, settings, the reference clock, receive captures, transmit, and transmit plus capture in one call, for measurement scripts. To use it, see automate a measurement; for complete scripts to copy, sweep both loopbacks and beamform across two boards.

No authentication, like iiod

Anyone who can reach port 7020 can retune the radio and read what it receives. Keep the board on a network you trust (networking).

It transmits only what a person has vouched for

Transmit and TransmitCapture are the only calls that can raise a transmitter, and only on a channel with an affirmation on record (./devkit tx-guard affirm 0 or 1, after looking at the port). No other call can: Configure reads both attenuators before and after, and mutes and fails if either rose. Mute is always allowed. The rules are under Transmitting.

Commands

# run from: the repo root on your PC
./devkit automation install      # put the server on the board, as a service
./devkit automation status       # the board, the radio, the clock, who holds the buffers
./devkit automation clock --measure          # time the reference against the board's crystal
./devkit automation capture loop --samples 2e6 --channels 1,2   # loop.sigmf-data and loop.sigmf-meta, here
./devkit automation smoke        # an end-to-end check; receive only
./devkit automation mute         # both transmitters to the floor, read back
./devkit automation test         # the unit tests, against a fake board (no board needed)
./devkit automation uninstall

BOARD overrides the address (default fishball.local). The client runs in its own venv, tools/automation/.venv, created on first use.

The calls

Defined in tools/automation/fishball.proto; any gRPC client can use them. The Python client's method is in the second column.

Call Python What it does
GetStatus status() model, firmware, kernel, both LOs, sample rate, bandwidths, each channel's gain, gain mode, RSSI and transmit attenuation, chip temperatures, whether a buffer is held and by which process, the clock
Configure configure(...) sets any of rx_lo_hz, tx_lo_hz, sample_rate_hz, rx_rf_bandwidth_hz, tx_rf_bandwidth_hz, rx1_gain_mode, rx2_gain_mode, rx1_gain_db, rx2_gain_db; returns the status read back afterwards
GetClock clock(measure, seconds) xo_correction and its range, the three PLL lock bits, and on request the measured reference
SetXoCorrection set_xo_correction(hz) tells the driver what the 40 MHz reference really runs at
Capture capture(samples, channels, path) records on the board, gap-free, into RAM; with path also fetches it as a SigMF pair and deletes it from the board
Fetch, DeleteCapture fetch(info, path), delete(id) download or remove a capture left on the board
Stream stream(channels, samples) samples as they are received; each block carries first_sample and dropped_samples
UploadWaveform upload_waveform(iq) sends a waveform to the board once, into RAM; complex samples at ±32767 full scale, one channel or one per transmitter, a multiple of 32 samples, at most 64 MB. Returns its id
DeleteWaveform delete_waveform(w) removes it
Transmit transmit(w, channels, attenuation_db, pad_db, seconds, one_shot, cyclic_bound_s) plays a waveform, repeating, or once with one_shot. The board plays while the call is open; the reply is a stream of the read-back state every 0.25 s. The Python method returns once the board confirms it is playing at the asked attenuation
TransmitCapture transmit_capture(w, samples, tx_channels, rx_channels, attenuation_db, pad_db, settle_s, path) plays a waveform, records the receivers while it plays, then mutes and stops. Returns a capture like Capture; its SigMF metadata records what was transmitted
Mute mute() stops a running transmit; both transmitters to −89.75 dB, read back

Transmitting

# run from: tools/automation, as: .venv/bin/python example.py
with board.transmit(w, channels=[1], attenuation_db=-40, pad_db=20) as tx:
    print(tx.state.tx1_attenuation_db)          # -40.0, read back from the chip
    rec = board.capture(samples=1_000_000, channels=[1], path="tone")
# here both transmitters read -89.75 dB, or stop() raised

How a transmit starts. Both attenuators are preset to −89.5 dB and read back, the buffer is started, both are read again, and only then is the asked attenuation written, and rewritten until the chip reads it. A channel not playing is held at −89.75 dB. Starting with both at exactly −89.75 dB would let the kernel restore the last stream's gain (why), and a value written before the start is not trusted after it. A one-shot has finished before anything could be written after the start, so its own attenuation is set first; it must therefore be above −89.5 dB.

How it ends. Both transmitters are muted and read back first, then the buffer is released: the other order hands the next program your gain. It ends when the client calls stop() or leaves the with block, when the client disappears (a crash, a kill -9, a lost network), when seconds have passed, when a one-shot has played, on Mute, and when the service stops; the systemd unit also mutes after the server exits, whatever way it exited. stop() returns only once the board reads both transmitters at the floor, and raises if it does not.

While it plays, the server reads both attenuators every 0.25 s. A value louder than asked mutes and fails the call. A playing channel found at the floor ends the call with the note "the board muted TXn by itself": the kernel's own bounds (the 60 s cyclic bound, the starve watchdog) do that.

One transmit at a time, and none while another program holds the transmit buffer. Configure is refused while one runs.

The repeating transmit is bounded by the board to tx_cyclic_timeout_ms, 60 s by default. A longer seconds is refused unless the request also sets cyclic_bound_s (1 to 3600 s); the server restores the board's own bound afterwards.

Transmit and capture are not started together by the hardware. The design has no shared start for the transmit and receive DMA, so where the waveform lands in a capture changes from one call to the next. For timing, capture a reference: loop the second transmitter into the second receiver and measure against that channel. Both see the same start, so their difference holds to a fraction of a sample (measured below).

What it refuses

Request Answer
a value the driver rejects FAILED_PRECONDITION, with the driver's own error: never reported as success
an unknown gain mode, channel or setting INVALID_ARGUMENT, before anything is written
Configure, Capture, Stream or a clock measurement while another program holds the buffer FAILED_PRECONDITION, naming the process (for example pid 311 (/usr/sbin/iiod …))
a capture larger than the board's free RAM INVALID_ARGUMENT, with both sizes
a clock measurement above 20 MS/s INVALID_ARGUMENT: lower sample_rate_hz first
a transmit on a channel with no affirmation FAILED_PRECONDITION: "TX1 has no affirmation on record. Look at TX1A: is it terminated, or going through an attenuator? Then run ./devkit tx-guard affirm 0"
an attenuation louder than −10 dB with pad_db under 20 INVALID_ARGUMENT. pad_db is the attenuator you have fitted between the transmitter and whatever it drives
TransmitCapture with pad_db under 20 INVALID_ARGUMENT: it loops a transmitter into a receiver, at any attenuation
a one-shot at −89.5 dB or quieter INVALID_ARGUMENT
a repeating transmit longer than the board's cyclic bound, without cyclic_bound_s INVALID_ARGUMENT, naming the bound
a second transmit, or Configure during one FAILED_PRECONDITION, "a transmit is running (Mute stops it)"
a waveform that is not a whole number of samples, not a multiple of 32, over 64 MB, or with more channels than transmitters named INVALID_ARGUMENT
an attenuation the chip does not take, or a transmitter that rises on its own FAILED_PRECONDITION; both muted and the buffer released before the answer

Measured on one board

Over Wi-Fi, board on gigabit Ethernet, 2026-10-05.

Capture, RX1 + RX2 at 20 MS/s 40 000 000 samples per channel (2 s, 320 MB) recorded in 2.2 s, 0 lost
Fetch 320 MB in 19.5 s: 16.4 MB/s
Live stream at 3 MS/s, one channel 2.20 MS/s delivered, 77 %; the rest counted as dropped
Live stream at 20 MS/s, one channel 3.33 MS/s delivered, 18 %; the rest counted as dropped
Reference clock, 30 s at 20 MS/s 40.00030 MHz, +7.5 ppm against the Zynq's own crystal
After a reboot the service is active again; both transmitters at −89.75 dB
Tone, 1 MHz from the LO at 868 MHz, TX1 at −40 dB → 20 dB → RX1, recorded by a separate Capture while Transmit ran at +0.118 Hz from 1 MHz; noise floor −86.8 dBc
The client killed with kill -9 while TX1 and TX2 played at −40 dB both read −89.75 dB 46 ms later, transmit buffer released
Pulsed chirp on TX1 and TX2, TransmitCapture on RX1 and RX2, three calls at 15.36 MS/s the pulse landed at samples 4295, 7372 and 4439; RX1 − RX2 was −0.002 samples each time
examples/loopback_sweep.py, TX1 → 20 dB → RX1 and TX2 → 30 dB → RX2 14 frequencies, 100 MHz to 5.8 GHz, in 15 s; between two runs the tone level moved at most 1.2 dB, the image up to 14.5 dB (the result)
examples/beamformer.py --emulate, one board, TX1 → 20 dB → RX1 and TX2 → 30 dB → RX2 five emulated directions from −60° to +60° found within 0.3°; lock check: coherence 1.000, phase spread 0.1° (the page)
examples/clock_stress.py PASS in 66 s: 48 rate changes, 200 retunes, tones within 0.12 Hz at 433.92, 868 and 2400 MHz with the worst spur at −58.3 dBc, reference +5.5 ppm. A later run failed at 2400 MHz: the spectra
  • A capture is the way to get every sample. It is recorded into RAM on the board at the radio's full rate, then fetched; its size is bounded by free RAM.
  • The live stream is for watching, at a few MS/s. The server is pure Python and sends about 13 MB/s. Whatever it cannot send, it drops and counts: dropped_samples is measured against the radio's own clock, so a loss anywhere between the radio and your script shows.
  • The clock measurement times the sample clock against CLOCK_MONOTONIC_RAW, the Zynq's own 33.333 MHz crystal. It tells a 40 MHz reference from a wrong one; over 30 s it resolves a few ppm.

The examples' figures

Each example in tools/automation/examples/ draws what it measured in an SVG beside its results, in the directory you run it from: loopback_sweep.svg, clock_stress.svg, beamformer-check.svg and the beamformer's scans. Open it in a browser; it follows your system's light or dark theme. The drawing code, svgplot.py, needs nothing installed.

clock_stress.svg from a run on 2026-10-05, unedited:

Three spectra, 4.8 MS/s, of a tone 1 MHz above the LO looped TX1 to 20 dB to RX1, at 433.92, 868 and 2400 MHz. Each tone landed 0.12 Hz from where it was sent. At 433.92 and 868 MHz the worst spurs are -57.5 and -59.1 dBc, under the -55 dBc limit drawn as a dashed line. At 2400 MHz two lines stand 0.19 MHz either side of the tone, at -46.2 and -46.9 dBc, above the limit, so this run failed. Three spectra, 4.8 MS/s, of a tone 1 MHz above the LO looped TX1 to 20 dB to RX1, at 433.92, 868 and 2400 MHz. Each tone landed 0.12 Hz from where it was sent. At 433.92 and 868 MHz the worst spurs are -57.5 and -59.1 dBc, under the -55 dBc limit drawn as a dashed line. At 2400 MHz two lines stand 0.19 MHz either side of the tone, at -46.2 and -46.9 dBc, above the limit, so this run failed.

At exactly 2400.000 MHz the tone can carry sidebands 0.19 MHz either side. Measured the same afternoon, TX1 → 20 dB → RX1:

LO Sideband at +0.813 MHz
2400 MHz, five calls in a row, one tune −46.1 to −46.3 dBc
2390 MHz and 2410 MHz −79.3 and −78.3 dBc: gone
2400 MHz again, after retuning −50.9 dBc
2400 MHz, two runs earlier that day worst spur −58.3 and −59.4 dBc: under the limit

It holds still within one tune, changed after a retune to the same frequency, and stands symmetric about the tone, which points at the radio's frequency synthesis: 2400 MHz is exactly 60 times the 40 MHz reference. An outside signal near 2400.8 MHz is not ruled out; at 2390 and 2410 MHz it would fall outside the captured band. Not investigated further: repeat a measurement at exactly 2400 MHz across retunes.

How it is built

Server library python3-grpclib, which is pure Python. Debian's python3-grpcio aborts on this board (time_posix.cc: assertion failed: ts.tv_nsec), a 64-bit-time fault in its armhf build
Board packages python3-grpclib, python3-protobuf and python3-libiio, from apt. No pip on the board
Radio access sysfs and debugfs for settings; iio_readdev -u local: for samples; libiio in the server's own process for transmit, so the buffer dies with it
Message types loaded at run time from fishball.desc, the compiled .proto: generated code is tied to one protobuf version, and the board (3.21) and a PC differ
Service fishball-automation.service; like iiod, it Requires=fishball-rf-quiesce, so it starts only if the boot-time transmitter mute was confirmed. ExecStopPost writes −89.75 dB to both attenuators after the server exits, however it exited
Captures and waveforms in /dev/shm/fishball-automation (RAM); captures removed when fetched, and all of it when the server stops
Tests 67 for the server, with the real server and client over localhost and only the board faked. The fake board copies the kernel's cached-gain restore, so a test fails if any transmit starts with both attenuators at the floor or is torn down before the mute. Run by the Host tools workflow, with 11 more for the beamformer's signal processing on synthetic arrays; the Hardware workflow runs smoke on the board, which does not transmit