pyMSO5000
A Python library and MCP server for controlling Rigol MSO5000 series oscilloscopes over VISA (pyvisa / pyvisa-py).
Every call is validated against the SCPI command definitions extracted from the scope's own firmware - arity, value types and enum spellings - so a bad argument is rejected locally instead of becoming a silent entry in the scope's error queue.
Installation
pip install pymso5000
Two optional extras:
pymso5000[mcp]- the MCP server for AI agents.pymso5000[firmware]- extracting SCPI definitions from a firmware.GELimage (also needs a system LZO development library). The bundled definitions need neither.
Quick start
Scope is the high-level API. It validates arguments before anything is sent,
canonicalizes the instrument's replies, and returns typed values:
from pymso5000 import Scope
with Scope.connect("TCPIP::192.168.178.102::INSTR") as scope:
print(scope.info().model) # 'MSO5074'
scope.set_channel(2, enabled=True, coupling="dc", scale_per_div=0.5)
scope.set_timebase(scale_s_per_div=1e-4)
scope.run()
print(scope.get_acquisition().sample_rate_sa_s) # 2000000000.0
Short SCPI spellings are accepted anywhere a mnemonic is: coupling="dc" reaches
the instrument as DC. A value the command cannot take is rejected locally,
before any I/O:
scope.set_channel(2, coupling="SIDEWAYS")
# ScopeUsageError: [usage] coupling must be one of ['AC', 'DC', 'GND'], got 'SIDEWAYS'.
Transports
Any message-based VISA resource works: TCPIP::<host>::INSTR (VXI-11), HiSLIP,
USBTMC, GPIB, a raw socket (TCPIP::<host>::5555::SOCKET) or a serial line;
framing is decided from the resource name. The bundled pyvisa-py backend covers
TCPIP out of the box; USBTMC additionally needs pyusb, serial needs pyserial,
GPIB needs gpib-ctypes or linux-gpib, and a full VISA implementation supplies
all of them.
Library usage
Subsystems
The densely-parameterized parts of the instrument hang off Scope as their own
objects:
from pymso5000.api.trigger_models import EdgeTriggerConfig
from pymso5000.api.measurement_models import WaveformMeasurementRequest
scope.generator.set(1, shape="SQUare", frequency_hz=10_000, amplitude_vpp=2.0, output_enabled=True)
scope.trigger.set(EdgeTriggerConfig(mode="EDGE", source="CHANnel2", level=0.0), sweep="AUTO")
results = scope.measure.measure(
[
WaveformMeasurementRequest(kind="waveform", item="FREQuency", source="CHANnel2"),
]
)
print(results.results[0].value, results.results[0].unit) # 10000.0 Hz
Waveforms
inspect reads statistics (and optionally a decimated preview) without keeping
the record; acquire keeps every point so it can be analyzed afterwards. Deep
memory is transferred in windows, with statistics accumulated in that single pass:
scope.stop() # RAW needs a stopped acquisition
wf = scope.waveform.acquire("CHANnel2", "RAW")
print(wf.point_count, wf.stats.peak_to_peak) # 2000000 2.075656
# No whole-capture exemption here, so the scan is bounded to one call's worth.
scan = wf.find_edges(0.0, direction="rising", stop_index=min(wf.point_count, 5_000_000))
print(1 / scan.intervals.mean_s) # 10000.0
print(wf.values(0, 5)) # first five samples, in volts
print(wf.summarize(0, 1000).stats.rms) # statistics over one sample range
values, summarize and find_edges operate on the retained record; the same
operations are available as pure functions in pymso5000.api.waveform_analysis.
A whole-capture summarize is free at any depth; find_edges is bounded to
5,000,000 samples per call, so a deeper capture is walked in windows.
Errors
A write the instrument refuses raises ScopeCommandError. Setters that apply
several settings at once complete the sequence and read the instrument back
first, because the scope does not roll back what already landed:
from pymso5000 import ScopeCommandError
try:
scope.generator.set(1, shape="PULSe", duty_cycle_pct=150.0)
except ScopeCommandError as exc:
print(exc.errors) # ['-200,"Command execute failed"']
print(exc.partial.state.duty_cycle_pct) # 20.0 - the shape applied, the duty cycle did not
Every error carries a kind and a retryable flag, so a caller can tell a bad
argument from a dropped link from an instrument in the wrong state without
matching on message text.
Rolling the instrument back
saved_setup() exports the whole instrument setup and puts it back when the
block ends - the rollback point for anything that reconfigures the scope broadly,
such as autoscale():
with scope.saved_setup():
scope.autoscale()
print(scope.measure.measure([...]))
# vertical, horizontal and trigger settings are as they were
Restoration runs on both exit paths; a failed restore during an exception is
logged rather than raised, so it cannot hide the failure that triggered it. The
setup blob does not include the built-in generator, so save scope.generator.get
separately when changing the AWG.
Anything not wrapped
Scope.execute runs any of the 2236 firmware commands, still validating its
arguments against the command's own definition:
print(scope.execute("CHANnel2:SCALe?")) # 0.5
scope.execute("CHANnel2:SCALe", [0.2])
ScpiCatalog searches and describes that command set, and needs no connection at
all:
from pymso5000 import ScpiCatalog
catalog = ScpiCatalog.bundled()
described = catalog.describe("CHAN1:COUP?") # short forms resolve
print(described.outputs[0].enum_values) # ['AC', 'DC', 'GND']
print(described.documentation.short_description)
CommandDocs bundles the programming guide's documentation - what each command
does - and matches it to command strings, short forms and firmware items:
from pymso5000 import CommandDocs, ScpiCatalog
docs = CommandDocs.load() # bundled, cached
doc = docs.find(":BUS1:SPI:TIMeout:TIME?") # long form (case-insensitive)
print(doc.render()) # syntax, description, params, examples
item = ScpiCatalog.bundled().resolve("CHAN1:SCAL") # short form -> firmware item
print(docs.find_for_item(item).short_description) # for an alias, pass item.target
The low-level client
MSO5000 is the transport underneath: it serializes a command and parses the
reply (including TMC binary blocks for screenshots, waveforms and setups), with
no connection management on top:
from pymso5000 import MSO5000, load_bundled_scpi_config
cfg = load_bundled_scpi_config()
with MSO5000.create_from_resource_name("TCPIP::192.168.178.102::INSTR") as scope:
print(scope.execute_command(cfg, "CHANnel1:SCALe?")) # -> 0.2 (float)
scope.execute_command(cfg, "CHANnel1:COUPling", ["AC"])
image = scope.execute_command(cfg, "SAVE:IMAGe:DATA?") # -> numpy ndarray
Definitions can also come from a firmware image rather than the bundled copy:
from pymso5000 import SCPIConfig, get_scpi_definition_files_from_firmware
cfg = SCPIConfig.create_from_file_dictionary(
get_scpi_definition_files_from_firmware("resources/DS5000Update_01.03.03.00.GEL")
)
The definitions the package ships live in src/pymso5000/data/scpi_mso5000/, and
are the only copy. tests/test_firmware_extraction.py extracts the image above and
asserts the result matches them byte for byte, so the shipped definitions are
checked against the firmware rather than against a second copy of themselves.
MCP server (for AI agents)
An MCP (Model Context Protocol) server exposes the scope to AI agents, including screenshot and touchscreen/front-panel control.
Install the optional dependency and run it (stdio transport):
uv sync --extra mcp # or: pip install 'pymso5000[mcp]'
pymso5000-mcp --resource TCPIP::192.168.178.102::INSTR
Example client configuration:
{
"mcpServers": {
"mso5000": {
"command": "uv",
"args": ["run", "pymso5000-mcp"],
"env": { "PYMSO5000_RESOURCE": "TCPIP::192.168.178.102::INSTR" }
}
}
}
⚠️ What this server can do to your instrument
By default the server can change the state of real hardware on your bench. It provides normal operator access: acquisition, channels, trigger, timebase, waveform-generator and display settings; reset/recall; and unrestricted touchscreen, key and knob controls. Reversible preferences such as date/time, language, beeper, screen saver and power-on behavior are also available.
Direct SCPI operations with persistent, administrative or service-level effects are divided into six permissions, all disabled by default:
| Risk category | Examples |
|---|---|
storage |
Save/export files, stored setup slots (*SAV), reference saves |
connectivity |
LAN configuration/application, GPIB address, remote server configuration |
security |
Password clearing, web-control reset, front-panel/remote locking |
calibration |
Factory/service calibration registers and calibration-data writes |
licensing |
Option installation and removal |
firmware |
Flash writes, nonvolatile clearing and undocumented low-level service operations |
Enable only the categories needed by a deployment; repeat the option for multiple categories:
pymso5000-mcp --resource TCPIP::192.168.178.102::INSTR \
--allow-risk storage \
--allow-risk connectivity
Matching happens on the resolved canonical command and alias target, so a short
form such as CAL:ADC:REG cannot bypass the calibration restriction.
scpi_describe reports risk_category, required_permission and
allowed_by_policy before a caller attempts execution.
The categories guard direct SCPI execution. Raw touch_tap, press_key and
turn_knob are deliberately a trusted, front-panel-equivalent lane and can reach
anything available through the scope's menus, including storage and service
operations. Do not expose these tools to an untrusted caller expecting the SCPI
categories to form a strict sandbox.
Configuration
Definition-source precedence: --scpi-dir > --firmware <file.GEL> > bundled
definitions. Every flag has an environment-variable equivalent:
| Flag | Environment variable |
|---|---|
--resource (required) |
PYMSO5000_RESOURCE |
--scpi-dir |
PYMSO5000_SCPI_DIR |
--firmware |
PYMSO5000_FIRMWARE |
--timeout-ms (default 10000) |
PYMSO5000_TIMEOUT_MS |
--chunk-bytes (default 1048576, 4096-67108864) |
PYMSO5000_CHUNK_BYTES |
--waveform-store-max-mib (default 256) |
PYMSO5000_WAVEFORM_STORE_MAX_MIB |
--waveform-store-max-captures (default 32, max 100) |
PYMSO5000_WAVEFORM_STORE_MAX_CAPTURES |
--waveform-capture-ttl-s (default 1800) |
PYMSO5000_WAVEFORM_CAPTURE_TTL_S |
--setup-store-max-mib (default 16) |
PYMSO5000_SETUP_STORE_MAX_MIB |
--setup-store-max-setups (default 32, max 100) |
PYMSO5000_SETUP_STORE_MAX_SETUPS |
--setup-ttl-s (default 1800, max 31536000) |
PYMSO5000_SETUP_TTL_S |
--allow-risk CATEGORY (repeatable) |
PYMSO5000_ALLOW_RISKS=storage,connectivity |
--log-level (default INFO) |
PYMSO5000_LOG_LEVEL |
The connection is opened lazily, so the server starts fine with the scope switched off. A structured audit line (tool, arguments, duration, outcome or error kind) is written to stderr for every call; stdout carries only the JSON-RPC stream.
Tools
- Screen & UI:
screenshot(1024x600 PNG whose pixels map 1:1 to touch coordinates),touch_tap,press_key,turn_knob. - Acquisition & setup:
run,stop,single,get_acquisition,set_acquisition,autoscale,get_trigger,set_trigger,instrument_info,get_channel,set_channel,get_timebase,set_timebase,get_generator,set_generator,upload_generator_waveform,export_scope_setup,restore_scope_setup,list_scope_setups,delete_scope_setup. - Logic analyzer:
get_digital,set_digital,configure_digital_group,autosort_digital,capture_logic(retains one capture per digital line, which then works with every retained-waveform tool). - Protocol decode:
get_decode,set_decode,read_decode_events,get_decode_threshold,set_decode_thresholdcover the fourBUS<n>decoders and their nine protocols. - Data: batched
measure,clear_measurement_items,get_measurement_reference_levels,set_measurement_reference_levels,configure_measurement_statistics,get_measurement_statistics,inspect_waveform,capture_waveform,read_waveform_samples,summarize_waveform_capture,find_waveform_edges,list_waveform_captures,delete_waveform_capture. - Generic SCPI:
scpi_search,scpi_describe,scpi_executecover the full firmware command set for anything the typed tools do not, and surface the programming guide's own documentation.scpi_executevalidates arguments against the command's firmware schema before writing anything, so a rejected call has no effect on the instrument.
Detailed per-tool behavior - side effects, transfer costs, cancellation semantics, and measured firmware quirks - is documented in the tool descriptions themselves, where the agents that call them can see it.
Resources and prompts
scpi-doc://command/{command}— the programming-guide entry for one command (works with the scope switched off; accepts long or short forms).scope://state— the whole setup in one read: run state, trigger, timebase, acquisition, all four analog channels, the logic analyzer and both generators. 62 VISA round trips with the analyzer off, and the digital block collapses to the master switch alone while it is.oscilloscope://captures/{capture_id}andoscilloscope://setups/{setup_id}— small JSON metadata manifests for retained captures and setups; samples and setup blobs are accessed only through bounded tools, never embedded.- Prompts
characterize_signalanddebug_no_triggerencode the screenshot → act → screenshot workflow for the two most common tasks.
Development environment (NixOS)
nix-shell # enters an FHS environment with uv
uv sync --extra firmware --extra mcp # include firmware extraction and MCP server
uv run pytest # offline parser/IO/MCP tests (no scope needed)
uv run ruff check # lint
uv run ruff format # format
uv run pyright # type-check
uv run python examples/first_test.py --resource TCPIP::192.168.178.102::INSTR
nix-shell is interactive; for a one-off command outside it, build the FHS wrapper
instead:
nix-build shell.nix -A fhs && ./result/bin/pymso5000-dev -c "uv run pytest"
Building the package with Nix
package.nix is a plain nixpkgs derivation - no flake and no uv, with every
runtime dependency taken from nixpkgs' default Python. Build it and run what was
built:
nix-build -E 'with import <nixpkgs> {}; callPackage ./package.nix {}'
./result/bin/pymso5000-mcp --resource TCPIP::192.168.178.102::INSTR
The build runs the offline suite and the import check, so a successful
nix-build is also a verification against nixpkgs' Python rather than against
the locked uv environment. nix-build shell.nix -A fhs writes the same
./result symlink, so pass -o result-fhs (or --no-out-link) when both are
wanted at once.
For a shell with the built server on $PATH rather than behind a symlink:
nix-shell -p '(callPackage ./package.nix {})' # then: pymso5000-mcp --help
For a shell holding the derivation's build environment - Python 3.14 with every dependency importable, and neither uv nor the FHS wrapper - use the derivation itself:
nix-shell -E 'with import <nixpkgs> {}; callPackage ./package.nix {}'
export PYTHONPATH=$PWD/src:$PYTHONPATH # prepend: assigning it drops the deps
pytest -q
An agent session started against the installed server tests whatever release is
on PATH, and the version string does not change when the working tree moves
ahead of it - a missing tool then looks like a missing feature. just opencode
builds the tree to ./result-mcp and starts opencode with only that server's
command overridden, so the global config's PYMSO5000_RESOURCE still applies:
just opencode # or: just opencode mcp list, to check which binary it starts
MCP servers are started once when the session starts and are not restarted while
it runs, so a rebuild reaches an agent only in a new session - which is what
just opencode is for, since it rebuilds before starting one.
The override lasts for that session only and does not change what
pymso5000-mcp means anywhere else. A packaged install - a Nix derivation
pinning the published sdist, say - keeps serving its release until that package's
version and hash are bumped, so after making a release, bump it there too.
opencode mcp list prints the binary each configured server actually starts,
which is the quickest way to tell an out-of-date install from a missing feature.
Both nix-shell forms resolve ./package.nix against the current directory, so
run them from the repository root. Imported that way the working tree carries no
installed distribution metadata, so pymso5000.__version__ falls back to
0.0.0+unknown and tests/test_mcp_server.py::test_version_is_resolvable fails;
the uv environment and the derivation's own check phase both install first, so
neither sees it.
The MCP server's published surface (tool names, descriptions, annotations and
JSON schemas, plus resources and prompts) is snapshotted in
tests/data/tool_schemas.json so an unintended change shows up as a diff. After an
intended change, regenerate it:
uv run pytest tests/test_mcp_schemas.py --snapshot-update
The same checks run in CI (.github/workflows/ci.yml).
Release files for pymso5000 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pymso5000-0.2.0.tar.gz | 693.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pymso5000-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.2 MB
Release files / pymso5000-0.2.0.tar.gz
| Download URL | pymso5000-0.2.0.tar.gz |
|---|---|
| Size | 693.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d567097d388866cc4c7baf33b17fe99630d7beccea068c5f3e7da2253f832469
|
|
BLAKE2b-256 checksum How to use checksums |
8becddb2ace373088ceb9186fea81321d72f6d9864fe74fb9d093db9f1a9896e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.
Transparency logRelease files / pymso5000-0.2.0-py3-none-any.whl
| Download URL | pymso5000-0.2.0-py3-none-any.whl |
|---|---|
| Size | 476.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
582063a08d233be992a988e46866dcec9c982b388c7e436dcee1d3bd6283ba48
|
|
BLAKE2b-256 checksum How to use checksums |
72edfb0783a3dbe864c53702f04083dbf7d29f5cb83d32e6f5921212a3526673
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.
Transparency log