A headless SCPI service for USB oscilloscopes.
There is no GUI and no CLI client. One long-running process watches a JSON file, and how you change that file is how you drive the instrument — an editor, a shell script, a notebook, a test rig, another program. The scope never knows the file exists.
you scope_server.py instrument
┌──────────────────┐ ┌──────────────────┐ ┌────────────┐
│ scope_config.json│ watch │ diff, apply, │ SCPI │ │
│ (state, yours) │ ───────► │ verify, report │ ───────► │ USB USBTMC │
│ scope_actions.json│ ◄────── │ reset to idle │ │ │
│ (requests) │ └──────────────────┘ └────────────┘
└──────────────────┘ │
▼
stdout is the UI
- No session state to lose. The JSON file is the desired state. Stop the server, edit, start it again — the scope is brought to whatever the file says.
- Any language, any tool. Writing JSON is the whole API. No Python import, no socket protocol, no SDK.
- Only changes travel. The settings block is diffed against what was last applied, so re-saving a file you barely touched sends one command, not twenty.
- Mistakes are reported, not sent. Values are checked before they go out, and settings that can be silently clamped are read back afterwards. A typo shows up in the console instead of looking like it worked.
- The scope is never a dependency. If the instrument is unplugged, the server keeps watching and reconnects on its own once the file changes again.
The console is the only interface, so it narrates everything: what it applied, what it read back, what it refused, and what it ignored.
pip install -r requirements.txt
# 1. point the config at a connected scope (interactive picker)
python set_resource.py
# 2. start the service
python scope_server.pyThen edit scope_config.json in any editor and save. The server notices within
a fraction of a second and applies the change.
{
"resource": "USB0::2391::6054::MY00000000::0::INSTR",
"settings": {
"channels": {
"2": {"display": true, "scale": 1, "probe": 10}
},
"timebase": {"scale": 1e-7},
"trigger": {"mode": "EDGE", "source": 2, "slope": "POS", "level": 2},
"run": 1
}
}Stop with Ctrl+C.
python scope_server.py # ./scope_config.json
python scope_server.py D:\rig\keysight.json # a different config
python scope_server.py --requests other.json # a different request panel| argument | meaning |
|---|---|
config |
config file to watch (default scope_config.json beside the script) |
--requests PATH |
request file (default scope_actions.json beside the config) |
Watching C:\...\scope_config.json
Requests C:\...\scope_actions.json
Connecting to USB0::2391::6054::MY00000000::0::INSTR ...
Connected: AGILENT TECHNOLOGIES,DSO-X 3024A,MY00000000,02.38.2014110300
Applying settings:
-> :CHANnel2:PROBe 10
-> :CHANnel2:DISPlay 1
-> :CHANnel2:SCALe 1
-> :TIMebase:SCALe 1e-07
-> :TRIGger:MODE EDGE
-> :TRIGger:EDGE:SOURce CHANnel2
-> :TRIGger:EDGE:SLOPe POS
-> :TRIGger:EDGE:LEVel 2
-> :RUN
** capture_png -> captures/hit_20260923-174501.png (48213 bytes)
| file | role | who writes it |
|---|---|---|
scope_config.json |
state — resource plus the settings you want |
you only — the server never opens it for writing |
scope_actions.json |
requests — a panel of one-shot actions | you change a value; the server puts the 0 back |
settings is level-based: it describes what the instrument should look like, and
the server diffs it, so an unchanged value sends nothing.
Requests are edge-based: they are events, not state. Keeping them in a separate file means a stale editor buffer can only ever lose a request — never one of your settings. The config file is not opened for writing at all.
The VISA resource string of the instrument. Required — a config without it
stops the server. Do not hand-write it: run python set_resource.py, which lists
what is actually connected and writes the choice into the file. Serial numbers
appear here, so this file shows your hardware — keep that in mind before
publishing a fork.
Everything the server knows how to control lives under here. Four sections:
| item | unit | meaning |
|---|---|---|
scale |
V/div | vertical scale |
offset |
V | vertical offset |
probe |
ratio | probe attenuation; 10 means a 10:1 probe |
display |
bool | true/1 shows the channel, false/0 hides it |
probe is always written before the other items on its channel, because
changing the attenuation makes the instrument rescale the vertical scale — a
probe write landing after a scale write would silently undo it.
display accepts booleans and numbers only. The strings "ON"/"OFF" are
rejected rather than guessed at: 1 if "OFF" else 0 is 1, so accepting
"OFF" would switch the channel on while the file said off. You get a warning
instead.
| item | unit | meaning |
|---|---|---|
scale |
s/div | horizontal scale |
offset |
s | horizontal position |
mode is required as soon as any parameter is given: the SCPI prefix depends on
it (:TRIGger:EDGE:LEVel versus :TRIGger:GLIT:LEVel), and the mode is applied
first whatever order the JSON uses. source is a channel number 1–4 and is
written as CHANnel2.
{
"settings": {
"trigger": {"mode": "EDGE", "source": 2, "slope": "POS", "level": 2.4}
}
}| mode | parameters |
|---|---|
EDGE |
source slope level coupling |
GLIT |
source polarity qualifier level |
PATT |
qualifier |
RUNT |
source polarity qualifier time |
TRAN |
source slope qualifier time |
TV |
source polarity mode standard line |
SHOL |
slope |
USB, DEL |
mode only; parameter names not identified |
Only modes measured to exist on a real instrument are accepted. An unknown mode
or parameter is reported, not sent — the scope answers an unknown mode with
-224 and quietly keeps the previous one, so a blind write would look like it
succeeded. See commands/trigger_reference.md
for what was measured, including the Rigol command set for comparison.
1 starts acquisition (:RUN), 0 stops it (:STOP).
The server reports what it does not understand, with a pointer to where you are looking in the file:
! ignoring unknown top-level key 'trigger' (did you mean settings.trigger?)
! ignoring unknown item 'channels.2.scal' (valid: scale, offset, probe, display)
! bad value for 'channels.2.display': expected true/false or 1/0, got 'OFF'
! trigger.mode: mode 'EGDE' is not available on this instrument (valid: DEL, EDGE, GLIT, PATT, RUNT, SHOL, TRAN, TV, USB)
! channels.2.probe=0.01 not applied as asked (scope reports 1.000000E+01, wrote 0.01)
That last one is the readback check. Rules that follow from it:
- Only what changed is sent. Values are diffed against the last apply, and the whole settings block is re-applied when the server connects to a different instrument.
- A rejected value is never retried in a loop. A clamped write is reported but still recorded as applied, so it is said once rather than on every poll.
- A JSON syntax error keeps the old state and changes nothing on the scope.
- A bad file edit is not fatal. Fix the JSON, save again, and the next poll picks it up. The server never exits because of a malformed config.
Requests are events, so they live in their own file and are shaped as a control
panel: every request the server knows sits in it at the idle value 0. Replace
a 0 with something real to ask for it, and the server edits the 0 back once
it has acted.
{
"single": 0,
"save_png": 0,
"capture_png": 0
}| request | what it does | needs a path |
|---|---|---|
single |
arms one acquisition and returns — the front-panel Single key | no |
save_png |
saves the screen as it is right now | yes |
capture_png |
arms one acquisition, waits for the trigger, then saves the screen | yes |
Replace exactly one value at a time — the panel is already a complete, valid file, so a request is a one-character edit:
"single": 1— arm one acquisition"save_png": "captures/shot_{timestamp}.png"— grab the screen now"capture_png": {"path": "captures/hit_{timestamp}.png", "timeout_s": 5.0, "resume": false}
Anything that spells "nothing" is idle: 0, false, null, "". Any other
value asks for the request, so 1, true and "go" all fire single.
save_png and capture_png read the screen over VISA (:DISPlay:DATA? PNG) and
write it straight to disk, so no USB stick is involved. Relative paths resolve
against the request file's folder, missing folders are created, and
{timestamp} expands to YYYYmmdd-HHMMSS. There is no counter field to bump:
the whole entry is reset, so the object form carries only real options.
| option | default | meaning |
|---|---|---|
timeout_s |
5 |
how long capture_png waits for a trigger |
resume |
false |
true returns to continuous acquisition after saving; the default holds the captured frame, exactly like the Single key |
An armed scope with no trigger waits forever, so the timeout matters: when it expires the request is reported as failed and nothing is written, rather than saving a stale screen and pretending it was the event.
The panel is a file you edit while the server writes it, so the rules are deliberately conservative:
- Every request is attempted once and then reset to idle, successful or not. A failure is reported and does not linger, so it cannot fire later when some unrelated edit happens to change the file.
- Only the value that was actually acted on is reset. Set
singleto2while1is still being carried out and the2survives to the next pass. - The file is re-read before the reset, so a request you added while the last one ran is not written away.
- A request left non-idle when the server starts is reset, not run — nobody is asking for it now — and the console names it.
- Requests are dropped, with a message, when no instrument is connected.
- Writes are atomic (temp file +
os.replace), so the watcher never reads a half-written file.
One consequence is worth spelling out: if your editor still shows
{"single": 1} after the server has reset it to 0, saving that buffer asks for
single a second time and re-arms the scope. It cannot corrupt a setting, but it
can do something you did not ask for. Most editors reload the file once the
buffer has no unsaved edits; otherwise keep it closed while you are not editing.
set_resource.py is the only part of this repo that talks to hardware without
the server running. It discovers instruments through the @py backend and writes
the one you pick into the config.
python set_resource.py # interactive picker
python set_resource.py --list --identify # show what is connected, exit
python set_resource.py -c keysight.json # a different config file
python set_resource.py --match MY00000000 # skip the prompt
python set_resource.py --vid 0x0957 --dry-run # preview, write nothingWith no flags it shows a numbered list and prompts: Enter keeps the currently
configured instrument (marked <- current), a number switches, q quits. In a
non-interactive context it will not prompt — it auto-selects only when exactly one
instrument is present, and otherwise exits non-zero telling you which flags to use.
The write is atomic, so a running scope_server.py picks up the change on its
next poll and reconnects by itself. Other keys in the config — your whole
settings block — are preserved.
Because the resource string carries the serial number, scope_config.json is
necessarily hardware-specific. A cleaned-up example is committed here.
| path | what it is |
|---|---|
scope_server.py |
the service: watches, diffs, applies, verifies, reports |
set_resource.py |
pick a connected scope and store it in a config file |
scope_config.json |
example state file |
scope_actions.json |
the request panel |
analyser/ |
FFT / CSV analysis for waveform exports — see analyser/README.md |
commands/ |
MSO5000 SCPI reference and measured trigger notes |
docs/usb-driver-setup.md |
Windows driver setup (Zadig / WinUSB, NI-VISA) |
rigol-mso5104-pyvisa-setup.md |
troubleshooting notes for a Rigol MSO5104 |
AGENTS.md |
conventions for AI agents working in this repo |
.githooks/prepare-commit-msg |
adds the AI co-author trailer — run git config core.hooksPath .githooks once |
LICENSE |
MIT — covers the code, not the vendor references in commands/ |
requirements.txt |
pyvisa + pyvisa-py + pyusb + libusb-package |
@pybackend only —pyvisa.ResourceManager("@py"); no NI-VISA install needed.- USB (USBTMC) is the tested path. Resource strings are discovered, never hardcoded.
- Timeout 5000 ms, termination
\nin both directions. - On Windows the scope may need its driver replaced with WinUSB via Zadig —
see
docs/usb-driver-setup.md. D:/in a SCPI path refers to the USB stick in the scope's front panel (a USB host port). Use forward slashes. The scope must be stopped before:SAVE:CSV.
Adding a control item means adding one method and naming it in the group's
ITEMS — it then appears as a JSON key on every channel (or on the timebase) at
once. The SCPI prefix lives only in the group instance, so no command string is
hand-written and a control cannot be aimed at the wrong channel by a copy-paste
slip. Add a <item>_readback() next to it when the instrument can silently
clamp or reject a value. Adding a request means adding a handler to REQUESTS.
Developed and verified against a Keysight DSO-X 3024A and a Rigol
MSO5104; the SCPI reference in commands/ covers the Rigol MSO5000 series.
Other MSO5000/DS1000Z-family instruments should work, but the trigger modes in
particular were measured on one instrument — see
commands/trigger_reference.md.
Commits carrying AI assistance record it as a trailer:
Co-authored-by: DeepSeek <noreply@deepseek.com>
.githooks/prepare-commit-msg writes that line automatically and leaves a
message that already has it alone. Hooks are not cloned with a repository, so
enable it once per clone:
git config core.hooksPath .githooksIt is a hook rather than a commit.template because a template is ignored as
soon as git commit -m is passed, and -m is how most commits are made.
DeepSeek wrote most of this repository, working as an AI coding agent under human direction — the requirements, the review and the final say are JohnQsk's.
MIT © 2026 JohnQsk — use it, modify it, ship it; keep the copyright notice.
That grant covers the code in this repository. It does not cover the Rigol
command references under commands/, which are vendor material:
commands/Rigol_MSO5000_SCPI_Commands.txt and
commands/Rigol_MSO5000_SCPI_Indexes.txt are references extracted from Rigol's
published MSO5000 programming documentation, included here for convenience. They
remain the property of their respective owner and are not covered by the MIT
license granted above.