Module pysdrlib.contributing
Adding a device to pysdrlib
A driver makes one radio look like every other: the same calls, the same units, samples as complex64. This page is the whole job, in order. Start from a generated skeleton:
python -m pysdrlib.scaffold my_radio # C++ extension + Python (most hardware)
python -m pysdrlib.scaffold my_radio --python-only # a Device written in Python alone
then fill in each TODO, checking as you go with pysdrlib.testing.
What a driver is made of
| File | What it holds |
|---|---|
<name>/<name>.py |
The Device subclass: the hooks below |
<name>/__init__.py |
Device = <class> and find() |
<name>/lib/__init__.py |
The compiled extension's import (its error classes, lib.err, come from the extension) |
<name>/README.md |
What the radio is, its specs, what to install |
_ext/<name>.py |
LIB (how the vendor library is found or built) and get() (the extension, for setup.py) |
_native/<name>/ |
<name>_device.hpp/.cpp (the wrapper) and <name>_pybind.cpp (its Python binding) |
tests/devices.py |
A Spec, which puts the driver under every contract test |
Nothing else is edited: ls() finds a driver by its Device = ... line, and setup.py builds every
_ext/<name>.py whose get() returns an extension. (Until it does, the driver is left out of the build.)
1. Describe the radio: CAPS
The class attribute CAPS is a Capabilities: limits, defaults and the gain stages by the names the hardware gives
them. Nothing generic knows these names.
CAPS = Capabilities(
driver="",
freq=[Range(1e6, 6e9)], default_freq=100e6,
rates=[Range(250e3, 20e6)], default_rate=2e6,
gains={"rx": {"AMP": GainStage(8, 0, 40, 16)}, "tx": {}}, # GainStage(step, min, max, default)
)
This is what sdr.caps reports before the device is open. Whatever only the attached unit knows (the tuner it has,
its real gain table, the rates its firmware offers) is added in Device._capabilities(caps) once open.
2. The Device class
A driver on an extension subclasses NativeDevice, which supplies receiving, the sample queue,
stall and drop detection, and read(). A driver implements only what is particular to its hardware:
| Hook | Does |
|---|---|
_open(serial_number=None, ...) |
Returns the extension's device. Map "not found" to err.NoDevice |
_set_rx_freq(freq), _set_rx_sample_rate(Fs) |
Tune the receiver (and the transmitter too, when DUPLEX is False: one synthesizer). Call self._rate_changed() after a rate change |
_set_tx_freq(freq), _set_tx_sample_rate(Fs) |
Only a DUPLEX transmitter, whose transmit side is tuned separately. Where tx and rx share a synthesizer these call the _set_rx_* hooks already |
_set_<dir>_<stage>_gain(gain) |
Apply one gain stage (one per stage in CAPS) |
set_<dir>_<stage>_gain(gain=None) |
Not written: made from CAPS for each stage (write one yourself only to document it better; it calls self._set_gain_stage("RX", "AMP", gain, self._set_rx_amp_gain)) |
_capabilities(caps) |
Refine caps for the attached unit |
_set_bandwidth, _set_antenna, _set_gain_mode, _set_clock_source, _set_bias_t |
Only the controls the hardware has. A control left out raises err.NotSupported |
_set_rx_channels, _set_tx_channels |
Only if it has several channels (see caps.channels, caps.shared) |
_tx_write(samples, timeout, slot), _start_tx, _stop_tx, _tx_check, _tx_stats |
Only if CAN_TRANSMIT |
Class attributes: NAME, CAPS, CAN_TRANSMIT, DUPLEX (separate receive and transmit synthesizers),
SAMPLE_FORMAT (the raw stream the extension delivers), RETUNE_SAMPLES and RETUNE_SETTLE (what a retune leaves in
flight and how long it takes to settle: measure them, as the comments in the shipped drivers do), SETTINGS (anything else the radio
has, as caps.Settings a user reaches with set_setting), GAIN_RESERVE and GAIN_BALANCE (how a total gain is split
across stages).
Two layers of API stay in step. The generic calls (set_rx_gain as a fraction of the range, set_rx_gain_db in decibels, which split a total over the stages)
work on every device. Each stage also has its own setter (set_rx_amp_gain) under the hardware's name. You provide only
CAPS's declaration of the stage and _set_<dir>_<stage>_gain, which applies it; the setter is made from them.
A hook takes the arguments the base class's does, including its keywords (_set_rx_freq(freq, channel=0),
_set_<dir>_<stage>_gain(gain, channel=0), _set_bandwidth(direction, bandwidth, channel=0), slot=0 on the receive and
transmit hooks): a driver with one channel ignores channel. testing.check_driver reports a hook that needs other
arguments or leaves a keyword out. A Setting whose method takes channel= sets per_channel=True.
Samples are complex64, full scale +-1. The extension converts from the hardware's format (SAMPLE_FORMAT) on its
streaming thread; do not go through complex128, or float64 at all.
Out-of-range values are clamped with a warning (or raise err.InvalidValue when sdr.strict): the base class does
this from CAPS, so a driver never validates its own limits. _set_gain_stage snaps to the stage's step or table.
3. The extension (C++)
_native/<name>/ wraps the vendor's C API in a HardwareDevice (include/hardware_wrapper.hpp) and binds it with
hw::bind_device (include/hardware_pybind.hpp), which adds everything devices share: start and stop, the queue,
statistics. Your code:
- opens and closes the handle, and throws
HardwareError(code, message)on failure; - on its streaming thread, passes each buffer to
dispatch_rx(data, valid_bytes, buffer_bytes), and reports samples the hardware says it lost withskip_rx(count)so readers see the gap; - exposes the device's controls (
set_freq,set_sample_rate…) as methods, bound in<name>_pybind.cpp.
The error codes you throw are listed once, in <name>_pybind.cpp's hw::register_errors(m, "MyRadioError", {{code, "NAME", "doc"}, ...}): it makes the Python classes (lib.err.NAME, all subclasses of MyRadioError) and raises the right one for each code. _native/rtl_sdr/ (a vendor callback) and
_native/uhd/ (several channels, transmit) are complete examples.
The extension is built without linking the vendor library: its symbols resolve when _ext/<name>.py's LIB loads
the library at first import (installed, or built before by pysdrlib build <name> from the vendored sources with cmake, no
network). Importing never builds it. Vendored
sources go in <name>/vendor/, written by tools/vendor.py from a recipe in tools/vendor/<name>/ (vendor.toml: the upstream repo, a tag or commit, and which files to keep; mode = "copy" for a small library, the default traces a build of the whole upstream tree), so the repository holds only what the build uses. Give LIB.help a message a user can act on, LIB.driver your driver's name (it is what pysdrlib build <name> takes), and LIB.note a warning if building is slow.
4. Finding units: find()
<name>/__init__.py defines find(), returning a DeviceInfo(driver, label, serial, index, open_args) for each
attached unit. open_args is what Device.open takes to open that one ({"serial": ...} or {"index": ...}), which
is how open()(info) works. It must not open the radio, and must not raise when none is attached.
5. Check it
After changing a binding, regenerate the extension's type stubs (pip install pybind11-stubgen, then
python -m pysdrlib._ext.stubs): editors read them, and tests/test_stubs.py fails until they list what the extension has.
from pysdrlib import testing
print(*testing.check_package("my_radio"), sep="\n")
testing.check_driver reports a gain stage with no setter, a Setting naming a method that does not exist, a
default outside its range, a missing SAMPLE_FORMAT, CAN_TRANSMIT without a _tx_write. Errors are broken; warnings
are missing polish. tests/test_conformance.py runs it on every driver.
Tests run without hardware. Add a Spec for the driver to tests/devices.py: it says how to encode a known sample
block in the raw format, what a fake of the extension returns, and which calls must fail on a closed device. Every
test_device_contract.py and test_native_contract.py test then runs on the driver over a fake. Put what is particular to
the radio (its gain stages, rate snapping, quirks) in tests/test_<name>_api.py over the same fake. A test that
needs the radio is marked hardware, runs only with PYSDRLIB_HW=1, and skips when none is attached.
6. Document it
The reference documentation is generated from docstrings: document the class, and every public method a driver adds,
as you would for a user. Fill in README.md: what the radio is, its specs, what to install, and what you measured.
Add it to the roadmap list in the top-level README.