Skip to main content
Glama
AnterCreeper

keyscope-mcp

by AnterCreeper
README.md
# keyscope-mcp

**AI-native oscilloscope control.** A pure-Python MCP service that lets LLM agents control Keysight EDUX1052G oscilloscopes via a compact DSL.

```
┌─────────────┐     DSL/SCPI      ┌──────────────┐     USBTMC     ┌──────────┐
│   AI Agent  │ ─────────────────→│  keyscope    │ ─────────────→│ EDUX1052G│
│  (Claude/…) │←─ waveform/data───│   -mcp       │←─ screenshot──│          │
└─────────────┘                   └──────────────┘               └──────────┘
```

## Features

- **Single MCP tool** (`scope_exec`) — ~800 tokens context vs 3000+ for 12 separate tools
- **TCL-like DSL** — Positional args, implicit units (`1V`, `10ms`, `1kHz`)
- **Fail-fast by default** — Stops on first error; `continue_on_error` for batch jobs
- **Side-effect tagging** — ○ none / ◒ soft / ● hard for AI visibility
- **Three-tier capability** — VERIFIED / EXPERIMENTAL / EXCLUDED (security)
- **Snapshot persistence** — Save/load instrument state via LMDB
- **Thread-safe** — Per-device lock for concurrent AI sessions

## Release Status

- Current stable baseline: **`v0.1.0`**
- Stability promise for `v0.1.x`:
  - Keep MCP tool name and input contract stable: `scope_exec(script, continue_on_error?)`
  - Prefer additive output changes (new fields) over breaking field renames/removals
  - Reserve potentially breaking interface changes for `v0.2.0+`
- Known limitation (LLM-facing): query syntax is not fully uniform across all commands yet (for example, `trig?` works while `chan 1?` may be inconsistent depending on parser path)

## Installation

```bash
pip install keyscope-mcp
# or from source
pip install -e .
```

### Local source install (recommended for development)

```bash
cd /root/keyscope-mcp
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -U pip
python -m pip install -e .
```

Verify install:

```bash
keyscope --list-commands
python -m keyscope_mcp --list-commands
```

## Quick Start

### 1. MCP Server (default mode)

```bash
# Auto-detects USB device
python -m keyscope_mcp

# Or specify address explicitly
python -m keyscope_mcp --address "USB0::10893::923::CN60121247::0::INSTR"
```

### 2. CLI Modes

```bash
# Interactive REPL
keyscope -i

# One-shot command
keyscope -c "idn"

# Execute script file
keyscope examples/power_ripple.dsl

# Dry-run (parse only, no hardware)
keyscope -n -c "rst; chan 1 on 1V dc; time 1ms"

# List all commands
keyscope --list-commands

# List VISA devices
keyscope --list-devices
```

### 3. Python API

```python
from keyscope_mcp.executor import execute

result = execute("""
chan 1 on 1V dc
time 1ms
trig edge chan1 0.5 pos
wgen on sin 10k 3.3 0
run
meas 1 freq vpp
""")

print(result["results"][-1]["value"])
# → {"freq": 10000.0, "vpp": 3.28}
```

### 4. OpenCode MCP setup (local)

Use OpenCode interactive MCP setup:

```bash
opencode mcp add
```

When prompted:
- Name: `keyscope`
- Type: `local`
- Command: `/root/keyscope-mcp/.venv/bin/python -m keyscope_mcp`

Check registration:

```bash
opencode mcp list
```

## Smoke Test (minimal)

### A. No-hardware smoke test

```bash
PYTHONPATH=. .venv/bin/pytest tests/test_dsl.py -q
python -m keyscope_mcp -n -c "idn; chan 1 on 1V dc; time 1ms; meas 1 vpp"
```

### B. MCP tool smoke test in OpenCode

In an OpenCode chat, run:

```text
Use MCP tool `keyscope.scope_exec` with script:
help
```

Expected: command reference text is returned.

### C. Hardware connectivity check (optional)

```text
Use MCP tool `keyscope.scope_exec` with script:
idn
```

Expected: Keysight ID string (for example, `Keysight Technologies,EDUX1052G,...`).

## DSL Quick Reference

### Channel
```
chan 1 on 1V dc           # CH1 on, 1V/div, DC coupling
chan 2 on 500mV ac bw20   # CH2 on, 500mV/div, AC, 20MHz BW limit
chan 1 off                # Turn off
```

### Timebase
```
time 1ms                  # 1ms/div
time 10us -5ms main       # 10us/div, -5ms offset, main mode
```

### Trigger
```
trig edge chan1 1.65 pos  # Edge trigger on CH1, 1.65V, positive slope
trig auto                 # Auto trigger mode
trig holdoff 100ns        # 100ns holdoff
```

### Acquisition
```
run                       # Continuous
sing                      # Single shot
stop                      # Stop
acq norm                  # Normal mode
acq aver 16               # Average 16 samples
```

### Measurements
```
meas 1 freq vpp           # Frequency and Vpp on CH1
meas 1 all                # All measurements
meas clear                # Clear all
```

### Waveform Capture
```
wave 1 10k word           # 10k points, WORD format
wave 1 max asc            # Max points, ASCII (slow but human-readable)
```

### Waveform Generator
```
wgen on sin 10k 3.3 0     # Sine 10kHz 3.3Vpp 0V offset
wgen on dc 1.65           # DC offset only (no frequency)
wgen on squ 1M 5 0        # Square 1MHz 5Vpp
wgen off                  # Turn off
```

### Math / FFT
```
math fft 1 10kHz 100kHz hann   # FFT of CH1, center 10kHz, span 100kHz
math sub 1 2                   # CH1 - CH2 waveform subtraction
math off                        # Turn off math
```

### Cursors
```
curs on chan1             # Enable cursors on CH1
curs x 0us 50us           # Set X cursors
curs y -1V 1V             # Set Y cursors
curs?                     # Read cursor values
curs off                  # Disable
```

### Snapshot
```
save baseline             # Save current setup
load baseline             # Restore setup
list                      # List saved snapshots
```

### Screenshot
```
shot png                  # Capture PNG screenshot
shot bmp                  # Capture BMP (larger, faster)
```

### Utility
```
idn                       # Query identity
rst                       # Reset to factory defaults
opc                       # Wait for operation complete
err                       # Check error queue
auto                      # Autoscale
help                      # Show help
help chan                 # Help for specific command
```

## Measurement Sentinel

When a measurement cannot be made (e.g., frequency with no signal), Keysight returns `~9.9e37`. keyscope-mcp normalizes this to:

```json
{"freq": null, "freq_invalid": true}
```

This lets AI agents distinguish "no measurement" from "zero" or invalid data.

## Examples

See `examples/` directory:

- **`power_ripple.dsl`** — Switching regulator ripple measurement
- **`digital_si.dsl`** — Clock signal integrity analysis
- **`fft_spectrum.dsl`** — Harmonic content analysis
- **`frequency_sweep.py`** — Frequency response (Bode plot approximation)

## Hardware Setup

### Minimal Setup
```
PC USB ───→ EDUX1052G (USBTMC)
WaveGen OUT ──balun──→ CH1 (10x probe)
```

### Dual-Channel Setup (stereo audio)
```
PC Audio tip   (left)  ──→ CH1 (1x probe, 100-200mV/div)
PC Audio ring  (right) ──→ CH2 (1x probe, 100-200mV/div)
PC Audio sleeve (gnd)  ──→ scope ground
```

## Supported Hardware

| Model | Bandwidth | WaveGen | Verified |
|-------|-----------|---------|----------|
| EDUX1052G | 50–200MHz | 100Hz–12MHz | ✓ |
| DSOX1102G | 70–100MHz | 100Hz–12MHz | ✓* |
| Other InfiniiVision | — | — | Likely* |

*Compatible SCPI command set; may need capability flags for advanced features.

## Architecture

```
keyscope_mcp/
├── __main__.py      # CLI entry point (MCP/REPL/script)
├── server.py        # MCP server (stdio/sse)
├── dsl.py           # DSL lexer, parser, 24-command registry
├── executor.py      # Fail-fast script engine
├── scope.py         # VISA connection, binary I/O, SCPI errors
├── units.py         # Human-readable unit parsing (1V → 1.0, 1ms → 0.001)
├── persist.py       # LMDB snapshot save/load with IDN validation
├── repl.py          # Interactive REPL
└── help.py          # Help text generation
```

## Testing

```bash
# Unit tests (no hardware required)
PYTHONPATH=. .venv/bin/pytest tests/test_dsl.py -v

# Device integration tests (requires EDUX1052G)
PYTHONPATH=. .venv/bin/pytest tests/test_device.py -v

# Stereo dual-channel device test only
PYTHONPATH=. .venv/bin/pytest tests/test_device.py -k stereo_audio_inputs -vv

# Manual stereo validation script
PYTHONPATH=. .venv/bin/python test_dual_channel.py

# XY oscilloscope music demo
PYTHONPATH=. .venv/bin/python examples/oscilloscope_music_demo.py --scale 50mV
```

Safety note:
- Audio output level is manual by design (scripts do not modify system volume).
- Start with low OS volume and increase gradually.
- Use scope vertical scale (`chan` settings or demo `--scale`) to improve visibility.
- Avoid headphones/speakers during high-level tuning.

## Troubleshooting

### No VISA devices found
```bash
# Check USB connection
lsusb | grep Keysight
# → Bus 001 Device 002: ID 2a8d:039b Keysight Technologies, Inc.

# Check kernel module
lsmod | grep usbtmc
# If present, may conflict with pyvisa-py backend:
sudo rmmod usbtmc
```

### Firmware hang after bad binary data
Physical replug required. Scope may need to re-initialize USBTMC state.

### Large waveform truncated
Fixed by `read_bytes()` with precise length parsing instead of `read_raw()`. See `scope.py:89-120`.

## License

MIT

## Contributing

Bug reports and PRs welcome. See `PLAN.md` for detailed architecture and SCPI taxonomy.

## Archive Handoff

`keyscope-mcp` is a small support asset for SiliconAIO / Noema / Autopilot, not a platform branch.

- Purpose: expose a Keysight oscilloscope as a single local MCP tool, `scope_exec`, backed by a compact DSL.
- Main entry: `python -m keyscope_mcp`
- Local install:
  `python3 -m venv .venv && . .venv/bin/activate && python -m pip install -e .`
- OpenCode local MCP command:
  `/root/keyscope-mcp/.venv/bin/python -m keyscope_mcp`
- No-hardware smoke test:
  `PYTHONPATH=. .venv/bin/pytest tests/test_dsl.py -q`
  `python -m keyscope_mcp -n -c "idn; chan 1 on 1V dc; time 1ms; meas 1 vpp"`
- Minimal MCP smoke test in OpenCode:
  call `keyscope.scope_exec` with `help`
- Optional hardware check:
  call `keyscope.scope_exec` with `idn`
- Known limits: stdio MCP only, Keysight-focused, no protocol-level CI, `.raw` is CLI-only expert path.
- If revisited later: prefer documentation and smoke-test maintenance, not architectural expansion.