skill / pyvisa-testscript
!

Not installable via adompkg

This skill has no published release. adompkg install kyle/pyvisa-testscript will not work until a maintainer publishes a tarball with install.sh and uninstall.sh.

See the publishing docs for the package.json schema and tarball layout required to ship this skill.


name: pyvisa-testscript
description: Use when the user wants to set up, run, configure, or extend the pyvisa-testscript instrument control system. Covers starting the live oscilloscope monitor, configuring instrument IP addresses and VISA connections, adding new oscilloscope or instrument drivers, writing PyVISA SCPI commands, and troubleshooting instrument connectivity. Trigger phrases include "pyvisa", "testscript", "oscilloscope monitor", "scope driver", "VISA instrument", "live waveform", "add instrument driver".

PyVISA Test Script — Instrument Control Skill

This skill covers the pyvisa-testscript project: a PyVISA-based instrument control system with a Flask web UI for live oscilloscope monitoring.

Repository: noah-adom-industries/pyvisa-testscript

Project Overview

The system connects to SCPI-capable instruments (oscilloscopes, DAQs) over TCP/IP using PyVISA, captures waveform data in a background thread, and serves a real-time browser UI via Flask + Plotly.

Architecture

main.py                     ← entry point: starts scope thread + Flask server
config.py                   ← IP addresses, VISA address, driver selection
drivers/
    base.py                 ← ScopeDriver abstract base class
    keysight_dsox1204g.py   ← Keysight DSOX1204G oscilloscope driver
    keysight_daq970a.py     ← Keysight DAQ970A data acquisition driver
    __init__.py             ← REGISTRY dict mapping driver names → classes
instruments/
    manager.py              ← InstrumentManager: auto-connect instruments via *IDN?
scope/
    controller.py           ← background capture thread (start/stop/single)
    state.py                ← ScopeState: thread-safe shared state
server/
    __init__.py             ← Flask app factory
    routes.py               ← REST API endpoints + SSE streaming
    templates/index.html    ← live monitoring UI (Plotly waveforms)

Key Concepts

  • ScopeDriver (drivers/base.py): Abstract base class. Every scope driver implements setup(), read_settings(), apply_settings(), grab_waveform(), and single_shot().
  • REGISTRY (drivers/__init__.py): Maps string names to driver classes. config.DRIVER_NAME selects the active driver.
  • ScopeState (scope/state.py): Thread-safe container holding current waveform data, settings, and status. Shared between the capture thread and Flask routes.
  • InstrumentManager (instruments/manager.py): Connects to instruments at startup via *IDN? query. Provides send_command() and query() for generic SCPI control.

Step 1: Clone and Install

cd /home/adom/project
git clone https://github.com/noah-adom-industries/pyvisa-testscript.git
cd pyvisa-testscript
pip install pyvisa pyvisa-py flask numpy

If using NI-VISA backend instead of pyvisa-py, install the NI-VISA runtime and use:

pip install pyvisa flask numpy

Step 2: Configure Instruments

Edit /home/adom/project/pyvisa-testscript/config.py:

# Primary oscilloscope
IP_ADDRESS   = "10.0.3.127"                          # scope IP on the network
VISA_ADDRESS = f"TCPIP0::{IP_ADDRESS}::5025::SOCKET" # SCPI socket address

# Driver selection — must match a key in drivers/__init__.py REGISTRY
DRIVER_NAME = "keysight_dsox1204g"

# Additional instruments auto-connected at startup
STARTUP_INSTRUMENTS: list[dict] = [
    {"ip": "10.0.3.83", "port": 5025},   # e.g. Keysight DAQ970A
]

To find instrument IPs, scan the network:

# Scan for instruments on SCPI port 5025
nmap -p 5025 10.0.3.0/24 --open

Step 3: Run the Server

cd /home/adom/project/pyvisa-testscript
python main.py

The server starts on http://0.0.0.0:5000. Open it in a browser to see live waveforms.

Step 4: Adding a New Oscilloscope Driver

To support a new oscilloscope model:

4a. Create the driver file

Create drivers/<manufacturer>_<model>.py. Subclass ScopeDriver and implement all abstract methods:

"""
Driver for <Manufacturer> <Model>.
"""
import struct
import numpy as np
from drivers.base import ScopeDriver


class MyNewScopeDriver(ScopeDriver):

    NAME = "My New Scope"
    N_CHANNELS = 4

    CHANNEL_COLORS = {
        "1": "#FFD700",
        "2": "#00E5FF",
        "3": "#FF00FF",
        "4": "#00FF88",
    }

    TIMEBASE_OPTIONS = [
        (5e-9, "5 ns"), (10e-9, "10 ns"), (20e-9, "20 ns"),
        (50e-9, "50 ns"), (100e-9, "100 ns"), (200e-9, "200 ns"),
        # ... add all supported timebases
    ]

    VSCALE_OPTIONS = [
        (0.001, "1 mV"), (0.002, "2 mV"), (0.005, "5 mV"),
        (0.01, "10 mV"), (0.02, "20 mV"), (0.05, "50 mV"),
        # ... add all supported voltage scales
    ]

    def setup(self, resource) -> None:
        """Configure VISA resource after connection."""
        resource.read_termination = "\n"
        resource.write_termination = "\n"
        resource.timeout = 5000
        resource.write("*RST")
        resource.write("*CLS")
        # Set waveform transfer format (binary, byte order, etc.)

    def read_settings(self, resource) -> dict:
        """Query scope and return settings dict."""
        timebase = float(resource.query(":TIMebase:SCALe?"))
        channels = {}
        for ch in range(1, self.N_CHANNELS + 1):
            scale = float(resource.query(f":CHANnel{ch}:SCALe?"))
            enabled = resource.query(f":CHANnel{ch}:DISPlay?").strip() == "1"
            channels[str(ch)] = {"scale": scale, "enabled": enabled}
        trigger_source = resource.query(":TRIGger:SOURce?").strip()
        trigger_slope = resource.query(":TRIGger:SLOPe?").strip()
        trigger_level = float(resource.query(":TRIGger:LEVel?"))
        return {
            "timebase": timebase,
            "channels": channels,
            "trigger": {
                "source": trigger_source,
                "slope": trigger_slope,
                "level": trigger_level,
            },
        }

    def apply_settings(self, resource, settings: dict) -> None:
        """Push settings dict to scope."""
        if "timebase" in settings:
            resource.write(f":TIMebase:SCALe {settings['timebase']}")
        if "channels" in settings:
            for ch, cfg in settings["channels"].items():
                if "scale" in cfg:
                    resource.write(f":CHANnel{ch}:SCALe {cfg['scale']}")
                if "enabled" in cfg:
                    resource.write(f":CHANnel{ch}:DISPlay {'1' if cfg['enabled'] else '0'}")

    def grab_waveform(self, resource, channels: list[int]) -> dict[str, np.ndarray]:
        """Capture waveform data from the specified channels. Return {ch: array}."""
        result = {}
        for ch in channels:
            resource.write(f":WAVeform:SOURce CHANnel{ch}")
            raw = resource.query_binary_values(":WAVeform:DATA?", datatype="h", is_big_endian=True)
            result[str(ch)] = np.array(raw, dtype=np.float64)
        return result

    def single_shot(self, resource) -> None:
        """Trigger a single acquisition."""
        resource.write(":SINGle")

4b. Register the driver

Edit drivers/__init__.py and add to REGISTRY:

from drivers.my_new_scope import MyNewScopeDriver

REGISTRY = {
    "keysight_dsox1204g": KeysightDSOX1204GDriver,
    "my_new_scope": MyNewScopeDriver,
}

4c. Select the driver

Set DRIVER_NAME = "my_new_scope" in config.py.

Step 5: Adding a New Instrument (non-scope)

For instruments managed by InstrumentManager (DAQs, multimeters, power supplies):

  1. Add the instrument to STARTUP_INSTRUMENTS in config.py:

    STARTUP_INSTRUMENTS = [
        {"ip": "10.0.3.83", "port": 5025},
        {"ip": "10.0.3.100", "port": 5025},   # new instrument
    ]
    
  2. Optionally create a dedicated driver in drivers/ with helper methods, then query it through the instrument manager's send_command() / query() API.

REST API Endpoints

The Flask server exposes these endpoints (defined in server/routes.py):

Endpoint Method Description
/ GET Live monitoring UI
/api/waveform GET Current waveform data (JSON)
/api/settings GET Current scope settings
/api/settings POST Apply new scope settings
/api/start POST Start continuous capture
/api/stop POST Stop capture
/api/single POST Single-shot acquisition
/api/stream GET SSE stream of waveform updates
/api/instruments GET List connected instruments
/api/instruments/<id>/command POST Send SCPI command to an instrument
/api/instruments/<id>/query POST Query an instrument (returns response)

Troubleshooting

Symptom Cause Fix
VI_ERROR_RSRC_NFOUND or connection timeout Instrument not reachable on the network Verify IP with ping <ip>. Check that port 5025 is open: nc -zv <ip> 5025. Ensure instrument and host are on the same subnet.
No driver found for ... DRIVER_NAME in config.py doesn't match any key in REGISTRY Check drivers/__init__.py — the name must match exactly.
Waveforms display but are flat/zero Waveform format mismatch (byte order, data type) Check grab_waveform() — ensure is_big_endian and datatype match the scope's binary format. Send :WAVeform:FORMat? to verify.
Flask starts but UI shows "Disconnected" SSE stream not connecting, or scope thread crashed Check terminal for Python exceptions. Verify the scope IP is correct and the instrument is powered on.
pyvisa-py can't find instruments Missing transport backend Install psutil and zeroconf: pip install psutil zeroconf. For USB instruments, install pyusb.
*IDN? query hangs or times out Instrument uses different termination characters Check the instrument manual. Some need \r\n instead of \n. Adjust in the driver's setup() method.
Multiple users see stale data Flask runs single-threaded by default main.py already sets threaded=True. For production use, run behind gunicorn: gunicorn -w 1 --threads 4 -b 0.0.0.0:5000 server:app.