OWON-VDS1022-MCP
by Jimbwlah
README.md
# 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](#how-it-works)
- [Requirements](#requirements)
- [Install](#install)
- [Register with Claude Code](#register-with-claude-code)
- [The golden rule](#the-golden-rule)
- [Using it — example prompts](#using-it--example-prompts)
- [Tool reference](#tool-reference)
- [Typical workflows](#typical-workflows)
- [Behaviour notes](#behaviour-notes)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Attribution & license](#attribution--license)
---
## 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 / VDS1022i
```
The USB protocol is not reverse-engineered here — that work already exists. This
project vendors the mature open-source driver from
[florentbr/OWON-VDS1022](https://github.com/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
```powershell
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 passed
```
> **numpy must stay < 2.** The vendored driver uses numpy-1.x integer promotion
> (`int8 + 128`); numpy 2 raises there. The pin is already in `pyproject.toml` —
> don't "upgrade" it away.
## Register with Claude Code
```powershell
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 `--env`
option is greedy and will swallow the command path otherwise.
- `--scope user` makes the scope available in every project; use `--scope project`
to write a shareable `.mcp.json` instead.
- `OWON_LIBUSB_DLL` tells pyusb where to find a libusb backend DLL. The path
above is where OWON's own software ships it. Omit only if `libusb0.dll` is
already on your `PATH`.
Verify: `claude mcp list` should show `owon-scope … ✔ Connected`.
Manual `.mcp.json` equivalent:
```json
{
"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).
- `mode` can be `EDGE`, `PULSE`, or `SLOPE`; for pulse/slope use conditions like
`RISE_SUP`/`FALL_INF` (width comparisons).
- `position` puts 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`/`ONCE` trigger is cleanly unblocked (`dev.stop()`) and reported.
- **One frame = 5000 samples** regardless of timebase; sample rate = 5000 / timerange.
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| `not found or locked by another application` | Scope unplugged, or the **VDS_C2 GUI is open** — close it. Also check Device Manager shows the device under *libusb-win32 devices*. |
| `No libusb backend found` | Set `OWON_LIBUSB_DLL` (see [registration](#register-with-claude-code)) or put a `libusb0.dll` on `PATH`. |
| Capture `timed out waiting for a trigger` | You're in `NORMAL`/`ONCE` sweep and the condition never fired. Use `sweep='AUTO'`, lower the level, or raise the timeout. |
| Voltages exactly 10× wrong | The `probe` setting doesn't match the physical x1/x10 switch on the probe. |
| `clipped: true`, measurements look railed | Signal exceeds the ADC window — increase `range` or call `scope_capture(autorange=True)`. |
| `vamp`/`duty_cycle` suddenly `null` after a dependency update | numpy was upgraded to 2.x — reinstall with `pip install "numpy<2"`. |
| 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
```
```powershell
.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](https://github.com/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`](NOTICE.md).
The MCP wrapper code (`src/owon_mcp/`) is © James Milward.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues