Skip to main content
Glama
Jimbwlah

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.

![real capture](docs/cal_capture.png)

*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.