Metadata-Version: 2.4
Name: pymso5000
Version: 0.1.0
Summary: Python client for Rigol MSO5000 oscilloscopes, driven by SCPI definitions extracted from the scope firmware
Project-URL: Homepage, https://github.com/mabl/pyMSO5000
Project-URL: Repository, https://github.com/mabl/pyMSO5000
Project-URL: Issues, https://github.com/mabl/pyMSO5000/issues
Project-URL: Changelog, https://github.com/mabl/pyMSO5000/blob/main/CHANGELOG.md
Author-email: Matthias Blaicher <matthias@blaicher.com>
License-Expression: MIT
License-File: LICENSE
Keywords: instrumentation,mcp,mso5000,oscilloscope,rigol,scpi,test-and-measurement,visa
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Typing :: Typed
Requires-Python: >=3.14
Requires-Dist: attrs>=25
Requires-Dist: numpy>=2.3
Requires-Dist: pillow>=12
Requires-Dist: pydantic>=2.11
Requires-Dist: pyvisa-py>=0.8
Requires-Dist: pyvisa>=1.16
Provides-Extra: firmware
Requires-Dist: python-lzo>=1.15; extra == 'firmware'
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.28.1; extra == 'mcp'
Description-Content-Type: text/markdown

# 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

```sh
pip install pymso5000
```

Two optional extras:

- `pymso5000[mcp]` - the MCP server for AI agents.
- `pymso5000[firmware]` - extracting SCPI definitions from a firmware `.GEL` image
  (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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.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()`:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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):

```sh
uv sync --extra mcp                       # or: pip install 'pymso5000[mcp]'
pymso5000-mcp --resource TCPIP::192.168.178.102::INSTR
```

Example client configuration:

```json
{
  "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:

```sh
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` |
| `--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_digital`,
  `set_digital`, `get_generator`, `set_generator`, `upload_generator_waveform`,
  `export_scope_setup`, `restore_scope_setup`, `list_scope_setups`,
  `delete_scope_setup`.
- **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_execute` cover the full
  firmware command set for anything the typed tools do not, and surface the
  programming guide's own documentation. `scpi_execute` validates 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}` and `oscilloscope://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_signal` and `debug_no_trigger` encode the
  screenshot → act → screenshot workflow for the two most common tasks.

## Development environment (NixOS)

```sh
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:

```sh
nix-build shell.nix -A fhs && ./result/bin/pymso5000-dev -c "uv run pytest"
```

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:

```sh
uv run pytest tests/test_mcp_schemas.py --snapshot-update
```

The same checks run in CI (`.github/workflows/ci.yml`).
