Python toolkit for driving a Digilent Analog Discovery 3 (AD3) through the WaveForms SDK for hardware-in-the-loop (HIL) test benches.
It bundles what a bench needs besides the device under test:
AnalogDiscovery3- supplies, static DIO, pattern generator, logic analyzer, wavegen, scope and the UART/SPI/CAN protocol engines, on a thinctypesbinding of the WaveForms SDK.analysis- pure signal analysis of captures: frequency, duty cycle, edges, dead time, phase, quadrature/SPI/UART decoding, statistics.FirmwareTerminal- a serial client for a line-based command terminal (OK/ERRfinal lines, asynchronousEVTlines) on the device under test, plus thead3-bench-consoleREPL.- A pytest plugin (
--ad3-serial,--ad3-remote,--no-ad3,--fake, thead3marker and fixture) and in-memory fakes of the WaveForms library and of a device terminal, so benches are unit tested without hardware. ad3-bench-server- shares the AD3 of one machine (for example a Windows PC) over TCP, so benches run in a Docker container or on another machine;ad3-bench-guiis the same server with a window and a tray icon, also shipped as a Windows installer and a Linux AppImage.
It was split out of the hal-ti hardware-in-the-loop validation, which uses it to validate TM4C drivers.
pip install ad3-waveforms-bench
# or the development version
pip install git+https://github.com/embedded-pro/ad3-waveforms-benchPython 3.10 or newer. The Python dependencies are pyserial and pytest; the WaveForms SDK is not a Python package but a native runtime that must be installed separately.
| OS | Install | Library loaded |
|---|---|---|
| Linux | Digilent Adept 2 runtime, then the WaveForms .deb/.rpm from the Digilent website |
libdwf.so |
| Windows | WaveForms installer from the Digilent website (includes the Adept runtime and the SDK) | dwf.dll |
| macOS | WaveForms .dmg from the Digilent website; the SDK framework is installed to /Library/Frameworks |
/Library/Frameworks/dwf.framework/dwf |
- Set
DWF_LIBRARYto the full path of the library when it is installed elsewhere. - The SDK constants come from the official
dwfconstants.pywhen it is found in the SDK samples directory (or inDWF_CONSTANTS_DIR); otherwise an identical built-in copy is used. - Without the runtime everything except opening a device works: the analysis, terminal and fakes are pure Python, and the
ad3fixture skips its tests. - A machine without the runtime (a container) can use the AD3 of another machine through
ad3-bench-server, see Remote AD3.
User-facing channel numbers are DIO 0-15, wavegen W1/W2 = 1/2 and scope channels 1/2. open() starts with every output released and V+/V- off; close() (or leaving the with block) returns to that state.
from ad3_waveforms_bench import AnalogDiscovery3
with AnalogDiscovery3(serial="210415ABCDEF", analog_limits=(0.0, 3.3)) as ad3:
ad3.supplies.set(vplus=3.3)
ad3.dio.drive(0, 1)
level = ad3.dio.read(1)
ad3.dio.release(0)Without serial (or index) the first device is opened. Wavegen levels outside analog_limits raise ValueError so a wrong parameter cannot overdrive an input.
ad3.pattern.pulses(2, count=10, frequency=1000)
ad3.pattern.wait_done()
ad3.pattern.clock(3, frequency=1e6, duty=0.25)
ad3.pattern.custom({4: [0, 1, 1, 0], 5: [1, 0, 0, 1]}, rate=1e6, run_samples=0)
ad3.pattern.quadrature(0, 1, frequency=1000, cycles=25, direction="rev", z=2, index_every=5)
ad3.pattern.stop()The methods return the frequency or sample rate actually used after quantisation to the device clock.
from ad3_waveforms_bench import analysis
capture = ad3.logic.record(rate=10e6, samples=16384, trigger=(0, "rising"), pretrigger=0.1)
capture.frequency(0), capture.duty(0), capture.edge_count(0, "rising")
capture.dead_times(0, 1)
capture.phase(0, 1)
capture.spi(clk=2, mosi=3, miso=4, cs=5, mode=0)
capture.uart(6, baud=115200, parity="even")
pending = ad3.logic.arm(rate=1e6, samples=8192, trigger=(7, "falling"))
# ... start the stimulus; the analyzer is already armed ...
capture = pending.wait(timeout=2.0)
bits = capture.channel(0)
analysis.high_low_times(bits, capture.rate)
analysis.quadrature_decode(capture.channel(0), capture.channel(1))LogicCapture holds 16-bit samples (bit n is DIOn); every helper is also available as a plain function in ad3_waveforms_bench.analysis for lists of 0/1 samples.
ad3.wavegen.dc(1, 1.65)
ad3.wavegen.sine(2, low=0.5, high=2.5, frequency=1000)
ad3.wavegen.square(2, low=0.0, high=3.3, frequency=500, cycles=10)
ad3.wavegen.wait_done(2)
volts = ad3.scope.average(1, samples=1000)
stats = ad3.scope.stats(2, rate=1e6, samples=4096)
traces = ad3.scope.acquire([1, 2], rate=1e5, samples=2000)Connect the - input of each used scope channel to ground for single-ended measurements.
ad3.uart.configure(tx=1, rx=0, baud=115200, parity="none", stop=1)
ad3.uart.write(b"hello")
reply = ad3.uart.read(5, timeout=0.5)
ad3.spi.configure(clk=0, mosi=2, miso=3, cs=1, frequency=1e6, mode=0)
rx = ad3.spi.transfer(b"\x9f\x00\x00\x00")
ad3.can.configure(tx=0, rx=1, bitrate=500_000)
ad3.can.send(0x123, b"\x01\x02")
frame = ad3.can.receive(timeout=1.0)The CAN engine works at logic level. Without transceivers, build a wired-AND bus: join the controller RX pin, the AD3 RX DIO and a 1 kOhm pull-up to 3.3 V, and let each TX pin (controller and AD3) pull that node low through a Schottky diode with its anode on the bus.
FirmwareTerminal talks to a command terminal on the device under test over a serial port (for example an EMIL services::TerminalWithCommandsImpl):
- The host writes one command per line,
name [positional...] [key=value...], terminated by\r. - The device may echo characters and print a prompt (
>and a space); echo, prompts, bells, backspaces and ANSI escape sequences are stripped. - Every command ends with exactly one final line:
OK [key=value...],ERR <reason>, or a line the terminal prints for unknown commands (Unrecognized command., reported as reasonunrecognized). EVT <kind> [key=value...]lines are asynchronous and may appear at any time, even between a command and its final line; they are queued.- Numbers are decimal or
0xhexadecimal, binary payloads are hex strings (a55a01,-for empty) and lists are comma separated.
from ad3_waveforms_bench import FirmwareError, FirmwareTerminal, format_command
with FirmwareTerminal("/dev/ttyACM0", baud=115200, timeout=2.0) as terminal:
terminal.sync()
info = terminal.command("info")
board, clock = info["board"], info.as_int("sysclk")
terminal.command(format_command("pwm.open", 0, gens=[0, 1], freq=20000, inva=True))
try:
terminal.command("can.open 0")
except FirmwareError as error:
print(error.reason)
event = terminal.wait_event("can", lambda event: event.as_int("id") == 0x123, timeout=1.0)
payload = event.as_bytes("data")
terminal.send_nowait("reset")
terminal.wait_boot(timeout=5.0)command()raisesFirmwareError(reason, command)onERR(check=Falsereturns theResponseinstead) andTerminalTimeoutwithout a final line.begin()writes a command without waiting and returns a pending command whosewait()collects the final line later.wait_event(),events(),drain_events()andcollect_events()read the event queue;sync()clears a half-typed line with Ctrl-C and waits untilping(or anothercommand) answers.ResponseandEventexposeargs,values,as_int,as_float,as_bool,as_bytes,as_listandas_ints.
The baud rate defaults to 115200; the prompt, the OK/ERR/EVT tokens, the unknown-command lines and the line terminator are set with a Dialect:
from ad3_waveforms_bench import Dialect
dialect = Dialect(ok="+OK", err="-ERR", event="!", prompt="$ ", unrecognized=("?",), line_terminator="\n")
terminal = FirmwareTerminal("/dev/ttyUSB0", baud=115200, dialect=dialect)ad3-bench-console --port /dev/ttyACM0 --baud 921600
ad3-bench-console --port /dev/ttyUSB0 --baud 115200 -c ping -c infoThe console sends lines as typed, prints final lines and events, and keeps a history in ~/.ad3_bench_console_history. :wait <s> listens for events, :events prints queued events, :raw also shows non-protocol output and :quit leaves. A project can wrap ad3_waveforms_bench.console.main(argv, prog=..., baud=..., history=...) to change the defaults.
Installing the package registers a pytest plugin (entry point pytest11) that adds:
| Option | Effect |
|---|---|
--ad3-serial SERIAL |
open the AD3 with this serial number (default: AD3_SERIAL, else the first device) |
--ad3-remote HOST[:PORT] |
use the AD3 of an ad3-bench-server (default: AD3_REMOTE) |
--no-ad3 |
skip every test marked ad3 or using the ad3 fixture |
--fake |
back the ad3 fixture with FakeDwfApi instead of the WaveForms runtime |
- The
ad3marker is registered, so it works with--strict-markers. - The session fixture
ad3opens the device once, skips the requesting tests when no device (or runtime) is available, and closes it at the end. - Override the
ad3_settingsfixture in aconftest.pyto change the analog limits or switch the supplies on. - Projects read
--fakethemselves to swap their own device fakes in, for example aFakeTerminalDevicebehind their terminal fixture.
# conftest.py
import pytest
from ad3_waveforms_bench.pytest_plugin import Ad3Settings
@pytest.fixture(scope="session")
def ad3_settings():
return Ad3Settings(analog_limits=(0.0, 3.3), vplus=3.3)# test_blink.py
import pytest
pytestmark = pytest.mark.ad3
def test_led_blinks(ad3):
capture = ad3.logic.record_for(0.5, rate=1e5)
assert capture.frequency(0) == pytest.approx(2.0, rel=0.05)pytest --ad3-serial 210415ABCDEF
pytest --no-ad3ad3_waveforms_bench.instruments.fake.FakeDwfApistands in for the WaveForms library behind the realAnalogDiscovery3. It records everyFDwf*call (calls_to(name)), loops DIO outputs back to inputs and feeds captures fromlogic_samples,uart_rx,can_rxandscope_levels.fake_ad3()returns an opened device on it.ad3_waveforms_bench.fake_terminal.FakeTerminalDevicebehaves like a device terminal (echo, prompt, Ctrl-C, backspace, events printed before the next final line, optional noise) with ahandlerstable of commands;FakeSerialconnects it toFirmwareTerminaland can return the output in random small chunks. Subclass it to emulate a particular firmware.
from ad3_waveforms_bench.fake_terminal import FakeSerial, FakeTerminalDevice
from ad3_waveforms_bench.instruments.fake import FakeDwfApi, fake_ad3
from ad3_waveforms_bench.terminal import FirmwareTerminal
api = FakeDwfApi()
ad3 = fake_ad3(api)
ad3.pattern.pulses(2, count=10, frequency=1000)
assert api.calls_to("FDwfDigitalOutConfigure")
device = FakeTerminalDevice(style="line")
device.handlers["get"] = lambda device, args, options: "OK value=1"
terminal = FirmwareTerminal(serial=FakeSerial(device, chunk=3), timeout=0.5)
assert terminal.command("get led").as_int("value") == 1The AD3 is plugged into one machine (typically Windows with the WaveForms runtime), the benches run somewhere else (a Docker container, a Linux VM, a CI runner). ad3-bench-server runs next to the hardware and shares the WaveForms library over TCP. The serial port of the device under test is not this package's job: forward it with a byte-stream bridge such as port-bridge and open it as a pyserial socket:// URL.
Docker container Windows host
┌──────────────────────────────┐ TCP 5025 ┌──────────────────────────────┐
│ pytest / AnalogDiscovery3 │ ─────────────▶ │ ad3-bench-server │── dwf.dll ── AD3 (USB)
│ RemoteDwfApi (AD3_REMOTE) │ FDwf* calls │ │
│ FirmwareTerminal │ TCP 5000 │ port-bridge │── COM5 ───── DUT
│ socket://host...:5000 │ ─────────────▶ │ │
└──────────────────────────────┘ └──────────────────────────────┘
On the Windows host, either install ad3-bench-server-<version>-windows-setup.exe from the releases (see GUI), or from Python:
pip install ad3-waveforms-bench port-bridge
ad3-bench-server
port-bridge --serial-port COM5 --serial-baudrate 115200In the container (Docker Desktop resolves host.docker.internal to the host):
export AD3_REMOTE=host.docker.internal:5025
pytest --ad3-remote host.docker.internal:5025 # or rely on AD3_REMOTE
ad3-bench-console --port socket://host.docker.internal:5000from ad3_waveforms_bench import AnalogDiscovery3, FirmwareTerminal
with AnalogDiscovery3(remote="host.docker.internal:5025") as ad3, FirmwareTerminal("socket://host.docker.internal:5000") as dut:
ad3.dio.drive(0, 1)
dut.command("ping")- Every
FDwf*call is forwarded with its ctypes arguments and out-parameters are written back (RemoteDwfApireplacesDwfApi), so the wholeAnalogDiscovery3wrapper runs unchanged in the container and new SDK functions need no server update. The constants come from the server's runtime. - Each call is one round trip (well under a millisecond on the same host). Acquisitions are single-shot and triggered on the device, so timing is not affected; only polling loops (UART/CAN receive) see the extra latency.
- One client at a time owns the AD3; others are refused with
busy. When a client disconnects (or crashes), every device it left open is returned to the safe state (outputs released, V+/V- off) and closed. AnalogDiscovery3(remote=...),AD3_REMOTE(andAD3_REMOTE_TOKEN) or--ad3-remoteselect the server; an explicitapi_factorystill wins.FirmwareTerminalandad3-bench-consoleaccept any pyserial URL as the port. Oversocket://the baud rate is the one the bridge opened the port with, not thebaudargument.ad3-bench-server --fakeservesFakeDwfApi, to try the setup without hardware.- The server listens on
127.0.0.1by default. If the container cannot reach it (for example Docker Engine inside WSL2 rather than Docker Desktop), use--host 0.0.0.0 --token <secret>, setAD3_REMOTE_TOKENin the container and keep the port behind the Windows firewall: whoever reaches the port controls the AD3.
examples/docker has a Dockerfile, a compose.yaml and an example test.
ad3-bench-gui runs the same server behind a window, with the layout and behaviour of the port-bridge GUI:
- WaveForms library (default location, a custom path or the fake device) with Detect devices, bind address, TCP port and token, log level.
- Status dots for the server and the connected client, a Start/Stop button and a live log panel; the log is also written to a rotating file (
%APPDATA%\ad3-bench-server\on Windows,~/.cache/ad3-bench-server/on Linux). - A tray icon (show/hide, start/stop, check for updates, open the log); closing the window keeps the server running in the tray. Settings are remembered.
--startstarts the server right away (as does Start the server on launch) and--minimizedstarts in the tray; the Windows installer's optional autostart entry uses both.- It checks the GitHub releases for a newer version at startup.
Every release attaches ad3-bench-server-<version>-windows-setup.exe (Inno Setup, no admin rights needed) and ad3-bench-server-<version>-x86_64.AppImage. From Python:
pip install "ad3-waveforms-bench[gui]"
ad3-bench-guiinstruments/dwf.pycalls theFDwf*C functions throughctypes, exactly like the official WaveForms SDK Python samples, and uses the officialdwfconstants.pyvalues.- This follows the SDK reference manual one to one, always matches the installed runtime (new devices and functions such as the AD3 protocol engines need no binding update) and adds no third-party dependency.
pydwfis a well-made wrapper, but it pins the API to its generated signatures and adds a translation layer to debug against the Digilent documentation.- All SDK calls are isolated in
instruments/dwf.pyandinstruments/ad3.py, soFakeDwfApican replace the library and the wrapper is unit tested offline.
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev,gui]"
ruff check .
ruff format --check .
mypy
pytest -qCI runs these on Linux, Windows and macOS with Python 3.10-3.14 and checks the built wheel. Releases are made by release-please from Conventional Commits and published to PyPI, see CONTRIBUTING.md.
MIT, see LICENSE.