{
  "schema_version": 1,
  "type": "skill",
  "slug": "pyvisa-testscript",
  "title": "PyVISA Instruments",
  "brief": "This skill covers the `pyvisa-testscript` project: a PyVISA-based instrument control system with a Flask web UI for live oscilloscope monitoring.",
  "version": "1.0.0",
  "tags": [],
  "license": "MIT",
  "source_path": "SKILL.md",
  "readme": "---\r\nname: pyvisa-testscript\r\ndescription: 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\".\r\n---\r\n\r\n# PyVISA Test Script — Instrument Control Skill\r\n\r\nThis skill covers the `pyvisa-testscript` project: a PyVISA-based instrument control system with a Flask web UI for live oscilloscope monitoring.\r\n\r\n**Repository:** `noah-adom-industries/pyvisa-testscript`\r\n\r\n## Project Overview\r\n\r\nThe 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.\r\n\r\n### Architecture\r\n\r\n```\r\nmain.py                     ← entry point: starts scope thread + Flask server\r\nconfig.py                   ← IP addresses, VISA address, driver selection\r\ndrivers/\r\n    base.py                 ← ScopeDriver abstract base class\r\n    keysight_dsox1204g.py   ← Keysight DSOX1204G oscilloscope driver\r\n    keysight_daq970a.py     ← Keysight DAQ970A data acquisition driver\r\n    __init__.py             ← REGISTRY dict mapping driver names → classes\r\ninstruments/\r\n    manager.py              ← InstrumentManager: auto-connect instruments via *IDN?\r\nscope/\r\n    controller.py           ← background capture thread (start/stop/single)\r\n    state.py                ← ScopeState: thread-safe shared state\r\nserver/\r\n    __init__.py             ← Flask app factory\r\n    routes.py               ← REST API endpoints + SSE streaming\r\n    templates/index.html    ← live monitoring UI (Plotly waveforms)\r\n```\r\n\r\n### Key Concepts\r\n\r\n- **ScopeDriver** (`drivers/base.py`): Abstract base class. Every scope driver implements `setup()`, `read_settings()`, `apply_settings()`, `grab_waveform()`, and `single_shot()`.\r\n- **REGISTRY** (`drivers/__init__.py`): Maps string names to driver classes. `config.DRIVER_NAME` selects the active driver.\r\n- **ScopeState** (`scope/state.py`): Thread-safe container holding current waveform data, settings, and status. Shared between the capture thread and Flask routes.\r\n- **InstrumentManager** (`instruments/manager.py`): Connects to instruments at startup via `*IDN?` query. Provides `send_command()` and `query()` for generic SCPI control.\r\n\r\n## Step 1: Clone and Install\r\n\r\n```bash\r\ncd /home/adom/project\r\ngit clone https://github.com/noah-adom-industries/pyvisa-testscript.git\r\ncd pyvisa-testscript\r\npip install pyvisa pyvisa-py flask numpy\r\n```\r\n\r\nIf using NI-VISA backend instead of `pyvisa-py`, install the NI-VISA runtime and use:\r\n```bash\r\npip install pyvisa flask numpy\r\n```\r\n\r\n## Step 2: Configure Instruments\r\n\r\nEdit `/home/adom/project/pyvisa-testscript/config.py`:\r\n\r\n```python\r\n# Primary oscilloscope\r\nIP_ADDRESS   = \"10.0.3.127\"                          # scope IP on the network\r\nVISA_ADDRESS = f\"TCPIP0::{IP_ADDRESS}::5025::SOCKET\" # SCPI socket address\r\n\r\n# Driver selection — must match a key in drivers/__init__.py REGISTRY\r\nDRIVER_NAME = \"keysight_dsox1204g\"\r\n\r\n# Additional instruments auto-connected at startup\r\nSTARTUP_INSTRUMENTS: list[dict] = [\r\n    {\"ip\": \"10.0.3.83\", \"port\": 5025},   # e.g. Keysight DAQ970A\r\n]\r\n```\r\n\r\nTo find instrument IPs, scan the network:\r\n\r\n```bash\r\n# Scan for instruments on SCPI port 5025\r\nnmap -p 5025 10.0.3.0/24 --open\r\n```\r\n\r\n## Step 3: Run the Server\r\n\r\n```bash\r\ncd /home/adom/project/pyvisa-testscript\r\npython main.py\r\n```\r\n\r\nThe server starts on `http://0.0.0.0:5000`. Open it in a browser to see live waveforms.\r\n\r\n## Step 4: Adding a New Oscilloscope Driver\r\n\r\nTo support a new oscilloscope model:\r\n\r\n### 4a. Create the driver file\r\n\r\nCreate `drivers/<manufacturer>_<model>.py`. Subclass `ScopeDriver` and implement all abstract methods:\r\n\r\n```python\r\n\"\"\"\r\nDriver for <Manufacturer> <Model>.\r\n\"\"\"\r\nimport struct\r\nimport numpy as np\r\nfrom drivers.base import ScopeDriver\r\n\r\n\r\nclass MyNewScopeDriver(ScopeDriver):\r\n\r\n    NAME = \"My New Scope\"\r\n    N_CHANNELS = 4\r\n\r\n    CHANNEL_COLORS = {\r\n        \"1\": \"#FFD700\",\r\n        \"2\": \"#00E5FF\",\r\n        \"3\": \"#FF00FF\",\r\n        \"4\": \"#00FF88\",\r\n    }\r\n\r\n    TIMEBASE_OPTIONS = [\r\n        (5e-9, \"5 ns\"), (10e-9, \"10 ns\"), (20e-9, \"20 ns\"),\r\n        (50e-9, \"50 ns\"), (100e-9, \"100 ns\"), (200e-9, \"200 ns\"),\r\n        # ... add all supported timebases\r\n    ]\r\n\r\n    VSCALE_OPTIONS = [\r\n        (0.001, \"1 mV\"), (0.002, \"2 mV\"), (0.005, \"5 mV\"),\r\n        (0.01, \"10 mV\"), (0.02, \"20 mV\"), (0.05, \"50 mV\"),\r\n        # ... add all supported voltage scales\r\n    ]\r\n\r\n    def setup(self, resource) -> None:\r\n        \"\"\"Configure VISA resource after connection.\"\"\"\r\n        resource.read_termination = \"\\n\"\r\n        resource.write_termination = \"\\n\"\r\n        resource.timeout = 5000\r\n        resource.write(\"*RST\")\r\n        resource.write(\"*CLS\")\r\n        # Set waveform transfer format (binary, byte order, etc.)\r\n\r\n    def read_settings(self, resource) -> dict:\r\n        \"\"\"Query scope and return settings dict.\"\"\"\r\n        timebase = float(resource.query(\":TIMebase:SCALe?\"))\r\n        channels = {}\r\n        for ch in range(1, self.N_CHANNELS + 1):\r\n            scale = float(resource.query(f\":CHANnel{ch}:SCALe?\"))\r\n            enabled = resource.query(f\":CHANnel{ch}:DISPlay?\").strip() == \"1\"\r\n            channels[str(ch)] = {\"scale\": scale, \"enabled\": enabled}\r\n        trigger_source = resource.query(\":TRIGger:SOURce?\").strip()\r\n        trigger_slope = resource.query(\":TRIGger:SLOPe?\").strip()\r\n        trigger_level = float(resource.query(\":TRIGger:LEVel?\"))\r\n        return {\r\n            \"timebase\": timebase,\r\n            \"channels\": channels,\r\n            \"trigger\": {\r\n                \"source\": trigger_source,\r\n                \"slope\": trigger_slope,\r\n                \"level\": trigger_level,\r\n            },\r\n        }\r\n\r\n    def apply_settings(self, resource, settings: dict) -> None:\r\n        \"\"\"Push settings dict to scope.\"\"\"\r\n        if \"timebase\" in settings:\r\n            resource.write(f\":TIMebase:SCALe {settings['timebase']}\")\r\n        if \"channels\" in settings:\r\n            for ch, cfg in settings[\"channels\"].items():\r\n                if \"scale\" in cfg:\r\n                    resource.write(f\":CHANnel{ch}:SCALe {cfg['scale']}\")\r\n                if \"enabled\" in cfg:\r\n                    resource.write(f\":CHANnel{ch}:DISPlay {'1' if cfg['enabled'] else '0'}\")\r\n\r\n    def grab_waveform(self, resource, channels: list[int]) -> dict[str, np.ndarray]:\r\n        \"\"\"Capture waveform data from the specified channels. Return {ch: array}.\"\"\"\r\n        result = {}\r\n        for ch in channels:\r\n            resource.write(f\":WAVeform:SOURce CHANnel{ch}\")\r\n            raw = resource.query_binary_values(\":WAVeform:DATA?\", datatype=\"h\", is_big_endian=True)\r\n            result[str(ch)] = np.array(raw, dtype=np.float64)\r\n        return result\r\n\r\n    def single_shot(self, resource) -> None:\r\n        \"\"\"Trigger a single acquisition.\"\"\"\r\n        resource.write(\":SINGle\")\r\n```\r\n\r\n### 4b. Register the driver\r\n\r\nEdit `drivers/__init__.py` and add to `REGISTRY`:\r\n\r\n```python\r\nfrom drivers.my_new_scope import MyNewScopeDriver\r\n\r\nREGISTRY = {\r\n    \"keysight_dsox1204g\": KeysightDSOX1204GDriver,\r\n    \"my_new_scope\": MyNewScopeDriver,\r\n}\r\n```\r\n\r\n### 4c. Select the driver\r\n\r\nSet `DRIVER_NAME = \"my_new_scope\"` in `config.py`.\r\n\r\n## Step 5: Adding a New Instrument (non-scope)\r\n\r\nFor instruments managed by `InstrumentManager` (DAQs, multimeters, power supplies):\r\n\r\n1. Add the instrument to `STARTUP_INSTRUMENTS` in `config.py`:\r\n   ```python\r\n   STARTUP_INSTRUMENTS = [\r\n       {\"ip\": \"10.0.3.83\", \"port\": 5025},\r\n       {\"ip\": \"10.0.3.100\", \"port\": 5025},   # new instrument\r\n   ]\r\n   ```\r\n\r\n2. Optionally create a dedicated driver in `drivers/` with helper methods, then query it through the instrument manager's `send_command()` / `query()` API.\r\n\r\n## REST API Endpoints\r\n\r\nThe Flask server exposes these endpoints (defined in `server/routes.py`):\r\n\r\n| Endpoint | Method | Description |\r\n|----------|--------|-------------|\r\n| `/` | GET | Live monitoring UI |\r\n| `/api/waveform` | GET | Current waveform data (JSON) |\r\n| `/api/settings` | GET | Current scope settings |\r\n| `/api/settings` | POST | Apply new scope settings |\r\n| `/api/start` | POST | Start continuous capture |\r\n| `/api/stop` | POST | Stop capture |\r\n| `/api/single` | POST | Single-shot acquisition |\r\n| `/api/stream` | GET | SSE stream of waveform updates |\r\n| `/api/instruments` | GET | List connected instruments |\r\n| `/api/instruments/<id>/command` | POST | Send SCPI command to an instrument |\r\n| `/api/instruments/<id>/query` | POST | Query an instrument (returns response) |\r\n\r\n## Troubleshooting\r\n\r\n| Symptom | Cause | Fix |\r\n|---------|-------|-----|\r\n| `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. |\r\n| `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. |\r\n| 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. |\r\n| 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. |\r\n| `pyvisa-py` can't find instruments | Missing transport backend | Install `psutil` and `zeroconf`: `pip install psutil zeroconf`. For USB instruments, install `pyusb`. |\r\n| `*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. |\r\n| 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`. |\r\n",
  "author": {
    "id": "695820315b5f1e4db2fcf602",
    "name": "Kyle Bergstedt",
    "email": "kyle@adom.inc"
  },
  "visibility": {
    "public": true
  },
  "hero": null,
  "sample_prompts": [],
  "discovery_triggers": [],
  "discovery_pitch": null,
  "metadata": {},
  "created_at": "2026-05-28T05:30:37.698Z",
  "updated_at": "2026-05-28T05:30:37.698Z",
  "sub_skills": [],
  "parent_app": null
}