OWON-VDS1022-MCP
OWON-VDS1022-MCP
Control an OWON VDS1022 / VDS1022i USB oscilloscope from Claude (or any MCP client). Configure the scope, capture waveforms, read measurements, export raw samples, decode serial protocols — and get the trace back as an image the AI can actually see, instead of reading the scope GUI and typing numbers by hand.

A real capture through this server: the scope's built-in 1 kHz probe-compensation square wave, measured at f = 1000.0 Hz, duty 49.9%.
Contents
How it works
Claude Code ──stdio (MCP)──▶ owon-mcp server (Python)
│ one shared VDS1022 handle, lock-guarded
▼
vendored vds1022 driver (pyusb + libusb0)
│ USB bulk transfers, VID 5345 / PID 1234
▼
OWON VDS1022 / VDS1022iThe USB protocol is not reverse-engineered here — that work already exists. This
project vendors the mature open-source driver from
florentbr/OWON-VDS1022 and wraps it
as 10 MCP tools. On first connect the driver automatically uploads the FPGA
bitstream (bundled in vendor/vds1022/fwr/) and reads the factory calibration
from the scope's flash — there is no manual firmware or calibration step.
Requirements
Hardware: OWON VDS1022 or VDS1022i (also sold as Multicomp MP720016/MP720017). USB identity
VID 5345 / PID 1234.OS: Windows (tested on Windows 11). The driver stack also works on Linux/macOS via libusb, but this repo's setup instructions are Windows-first.
USB driver: the libusb-win32 (
libusb0) driver that OWON's official VDS_C2 software installs. If you've ever run the official software on the machine, you already have it — no Zadig / driver swap needed.Python: 3.10 or newer.
Install
git clone https://github.com/Jimbwlah/OWON-VDS1022-MCP owon-scope
cd owon-scope
python -m venv .venv-win
.venv-win\Scripts\python.exe -m pip install -e ".[dev]"
# verify the install (no scope needed — runs against a mocked device)
.venv-win\Scripts\python.exe -m pytest -q # expect: 13 passednumpy must stay < 2. The vendored driver uses numpy-1.x integer promotion (
int8 + 128); numpy 2 raises there. The pin is already inpyproject.toml— don't "upgrade" it away.
Register with Claude Code
claude mcp add owon-scope "<ABSOLUTE-PATH>\.venv-win\Scripts\owon-mcp.exe" --scope user --env "OWON_LIBUSB_DLL=C:\Program Files (x86)\OWON\VDS_C2\USBDRV\amd64\libusb0.dll"Positional args (name, command) must come before
--env— the--envoption is greedy and will swallow the command path otherwise.--scope usermakes the scope available in every project; use--scope projectto write a shareable.mcp.jsoninstead.OWON_LIBUSB_DLLtells pyusb where to find a libusb backend DLL. The path above is where OWON's own software ships it. Omit only iflibusb0.dllis already on yourPATH.
Verify: claude mcp list should show owon-scope … ✔ Connected.
Manual .mcp.json equivalent:
{
"mcpServers": {
"owon-scope": {
"command": "D:\\projects\\owon-scope\\.venv-win\\Scripts\\owon-mcp.exe",
"env": {
"OWON_LIBUSB_DLL": "C:\\Program Files (x86)\\OWON\\VDS_C2\\USBDRV\\amd64\\libusb0.dll"
}
}
}
}The golden rule
⚠️ Close the official OWON VDS_C2 application before use. The scope allows exactly one program to hold its USB handle. If VDS_C2 (or a second MCP session) has it, connection fails with a clear error saying so. Conversely, while this server is using the scope, VDS_C2 won't see it.
Using it — example prompts
Once registered, just talk to Claude:
"Check the scope is connected" →
scope_status"Capture whatever is on the scope and show me" →
scope_capture"Set CH1 to 5 V range, DC coupling, x10 probe, then capture"
"Trigger on a rising edge through 1.5 V on CH2, 5 ms window, and show me the wave"
"What's the frequency and duty cycle on CH1?" →
scope_measure"Export the current waveform to CSV" →
scope_export"Decode the UART traffic on CH1 at 115200 baud" →
scope_decode
The first call that touches the scope takes a few seconds (FPGA upload + calibration read); everything after is fast.
Tool reference
All voltage/time/rate arguments accept human-friendly strings: '20v',
'500mv', '10ms', '50us', '100M', 'x10', 'CH1'.
scope_status()
Connects (if not already) and returns JSON: serial, hardware version, FPGA version, sampling rate, sweep mode, and per-channel config (range, probe, coupling, offset).
scope_set_channel(channel='CH1', range='20v', coupling='DC', probe='x10', offset=0.5)
Vertical setup. range is the full-scale volts across 10 divisions at the
probe tip (hardware ranges 50 mV–50 V per 10 div at x1; multiply by probe
factor). coupling is DC/AC/GND. offset positions zero volts on screen
(0 = bottom, 1 = top). Make probe match the physical switch on your probe,
or every voltage will be off by 10×.
scope_set_timebase(timerange=None, sample_rate=None)
Horizontal setup — give one or the other. timerange is the duration of the
5000-sample frame ('50us' … '2000s'); sample_rate is samples/sec
('2.5' … '100M').
scope_set_trigger(source='CH1', condition='RISE', level='0v', position=0.5, sweep='AUTO', mode='EDGE')
sweep='AUTO'free-runs: a capture always returns, triggered or not. Default.sweep='NORMAL'/'ONCE'wait for the condition (captures time out if it never fires).modecan beEDGE,PULSE, orSLOPE; for pulse/slope use conditions likeRISE_SUP/FALL_INF(width comparisons).positionputs the trigger point left↔right in the frame (0.5 = centre).
scope_autoset()
Auto-fit range/timebase/trigger to the signal (like the front-panel Auto button).
scope_capture(timeout=8, autorange=False) — the primary tool
Captures one frame from all enabled channels and returns both a JSON
measurement block (per channel) and a rendered PNG of the trace. Set
autorange=True to let it fix a clipped/too-small range automatically.
scope_screenshot(timeout=8)
Same capture, image only.
scope_measure(channel='CH1', timeout=8)
Numbers only, as JSON: vpp, vmin, vmax, vavg, vrms, vamp, vbase, vtop, freq_hz, period_s, phase_deg, duty_cycle, samples, clipped.
Metrics that don't apply (e.g. frequency of a DC level) come back null.
clipped: true means the signal exceeded the ADC window — increase the range.
scope_export(path, format='csv', timeout=8)
Captures and writes raw samples. CSV: time_s column + one volts column per
enabled channel (5000 rows). JSON: {time_s: [...], channels: {CH1: [...]}}.
scope_decode(channel='CH1', protocol='uart', baud=9600, timeout=8)
Captures and decodes a serial protocol from the trace. protocol='uart'
(set baud) or 'wire' (raw logic transitions).
Typical workflows
Sanity check / first use — clip a probe onto the scope's probe-compensation
tab (front metal tab, ~1 kHz square wave), then:
scope_autoset → scope_capture. Expect f ≈ 1000 Hz, duty ≈ 50%. A tilted or
overshooting top on the square means the probe's compensation trimmer needs
adjusting — which is exactly what that signal is for.
Measure a signal — scope_set_channel (range comfortably above the
expected Vpp, correct probe factor) → scope_set_timebase (aim for 2–10 periods
in the window) → scope_capture. If clipped: true, increase range or use
autorange=True.
Catch a one-shot event — scope_set_trigger(source, level=..., sweep='ONCE')
→ scope_capture(timeout=30). The capture returns when the event fires, or
times out cleanly (nothing hangs).
Log data for analysis — scope_export('capture.csv'), then analyse the CSV
however you like; the assistant can read it back and do the math.
Behaviour notes
Lazy connect: the server starts instantly; the USB connection (and the few-second FPGA upload) happens on the first tool call that needs the device.
Fresh-start defaults: if you capture before configuring anything, the server applies a safe free-running default (CH1, 20 V range, 10 ms window, AUTO sweep) so the first capture always works.
Config lives in the server process: settings persist across tool calls within a session; restarting the MCP server resets them (the scope itself keeps nothing).
Captures can't hang: every capture runs under a timeout; a waiting
NORMAL/ONCEtrigger is cleanly unblocked (dev.stop()) and reported.One frame = 5000 samples regardless of timebase; sample rate = 5000 / timerange.
Troubleshooting
Symptom | Cause / fix |
| Scope unplugged, or the VDS_C2 GUI is open — close it. Also check Device Manager shows the device under libusb-win32 devices. |
| Set |
Capture | You're in |
Voltages exactly 10× wrong | The |
| Signal exceeds the ADC window — increase |
| numpy was upgraded to 2.x — reinstall with |
First call is slow (~5 s) | Normal: FPGA bitstream upload + calibration read on first connect. |
Development
src/owon_mcp/
server.py FastMCP server, the 10 scope_* tools
device.py singleton device handle, lock, lazy connect, error mapping
measure.py Frame → measurements dict (defensive, never raises)
render.py Frames → oscilloscope-style PNG (matplotlib Agg)
vendor/vds1022/ vendored driver + FPGA firmware (do not edit)
tests/test_offline.py 13 mocked-device tests — run without hardware.venv-win\Scripts\python.exe -m pytest -q # offline test suite
.venv-win\Scripts\python.exe -m owon_mcp.server # run the server manually (stdio)Verified end-to-end on real hardware (VDS1022i, fw V5.0.1, FPGA v5): FPGA upload, channel/timebase/trigger config, 1 kHz cal-wave capture (f = 1000.0 Hz, duty 49.9%), PNG render, CSV export, clip detection.
Attribution & license
The USB driver in vendor/vds1022/ (and the FPGA firmware it uploads) is the
excellent open-source work of florentbr —
florentbr/OWON-VDS1022 — vendored
unmodified. The upstream repo carries no explicit license file; this
repository is private/personal-use. If you plan to make this public or
redistribute it, clarify licensing with the upstream author first. See
NOTICE.md.
The MCP wrapper code (src/owon_mcp/) is © James Milward.