How to Structure a PyVISA Resource Manager for Multi-Vendor Labs
A bench that mixes a Keysight source-measure unit, a Rohde & Schwarz spectrum analyzer, a Tektronix scope, and a serial syringe pump has one thing in common at the transport layer: every instrument speaks VISA, and every instrument disagrees on the details. Keysight and Tektronix waveform queries want a 1 MB transfer chunk; a flaky USB-to-serial bridge wants 1 KB. Rohde & Schwarz self-alignment can hold a session busy well past a 5-second timeout; a GPIB power supply answers in milliseconds. A single generic open_resource() that applies one set of session attributes to all of them will truncate one instrument’s binary block while timing another out — and it will do so intermittently, which is the worst way to fail. This page shows how to structure a resource manager that resolves each instrument to a vendor-specific session profile deterministically, from one shared backend, so heterogeneity is handled by data rather than by scattered if vendor == … branches.
Scope: Per-Vendor Session Normalization Behind One Interface
The narrow problem here is structural, not about discovery mechanics. Fail-fast resource-string validation, *IDN? capability probing, and the singleton-per-host backend rule are established on the parent VISA Resource Manager Setup page; this guide assumes those are in place and solves the next layer: given a validated session, apply the right termination, chunk size, timeout, and settling behavior for the specific vendor on the other end — without leaking vendor conditionals into the routing code. It targets Python 3.10+ with pyvisa 1.13 or newer, a single pinned backend per host (@ivi for a vendor IVI library, @py for pyvisa-py), and the four transport classes a typical lab exposes: LXI/USB-TMC benchtop instruments, GPIB legacy gear, and ASRL serial devices whose framing concerns belong to PySerial Configuration & Tuning. The output is a session already normalized for the Protocol Abstraction Layers that sit above it.
Mechanism: Deterministic Vendor-to-Profile Dispatch
The design turns the manufacturer field of *IDN? into a lookup key. A well-formed identity response is MANUFACTURER,MODEL,SERIAL,FIRMWARE, so the leading field — normalized for whitespace, corporate suffixes, and known aliases (an Agilent-branded unit predating the 2014 Keysight split reports Agilent, not Keysight) — becomes a registry key that maps to a frozen VendorProfile. Nothing is probed or guessed: the same instrument yields the same key yields the same profile on every run and every host, which is exactly the reproducibility property that makes a failed acquisition debuggable.
Session attributes are then resolved by a strict precedence. A caller override always wins; the vendor profile fills anything the caller left unset; a conservative global default backstops an unknown vendor. For any attribute a:
Keying on the manufacturer token alone is deliberately coarse and correct for the common case, where one vendor’s instruments share termination and transfer conventions. The reference profiles below capture the divergences that actually bite in a mixed fleet:
Manufacturer token (*IDN?) |
read/write termination | chunk_size |
timeout floor |
Notable quirk handled |
|---|---|---|---|---|
KEYSIGHT / AGILENT |
"\n" |
1 MB | 5000 ms | Large definite-length blocks on :WAV:DATA?; Agilent is the legacy alias |
ROHDE&SCHWARZ |
"\n" |
1 MB | 10000 ms | Self-alignment/calibration holds the session busy far past 5 s |
TEKTRONIX |
"\n" |
1 MB | 5000 ms | Curve queries need headers off (HEADER OFF) or the block is misframed |
NATIONAL INSTRUMENTS |
"\n" |
20 KB | 3000 ms | PXI/DAQ SCPI answers fast; no oversized transfers |
| (unknown) | "\n" |
20 KB | 3000 ms | Conservative fallback; logs a warning so the miss is visible |
Production Implementation: A Vendor-Aware Resource Manager
The manager holds one backend and one ResourceManager per host, exposed as a process-wide singleton. open() bootstraps just enough of a session to read a clean *IDN?, resolves the vendor profile, reconfigures the session from that profile with caller overrides taking precedence, then synchronizes with *CLS / *OPC?. Any failure after the handle is acquired closes it — a half-configured session is never leaked back to the caller.
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from threading import Lock
from typing import Optional
import pyvisa
from pyvisa.errors import VisaIOError
from pyvisa.resources import MessageBasedResource
logger = logging.getLogger(__name__)
# Interface prefix (optional board number) + "::"-separated address fields +
# a resource-class suffix. INSTR = session instruments; SOCKET/RAW = raw endpoints.
_RESOURCE_RE = re.compile(
r"^(?:TCPIP|USB|GPIB|ASRL|VXI|PXI)\d*(?:::[\w./\-]+)*::(?:INSTR|SOCKET|RAW)$"
)
class ResourceStringError(ValueError):
"""Raised when a VISA resource string fails fail-fast structural validation."""
class VendorResolutionError(RuntimeError):
"""Raised when *IDN? is malformed and no manufacturer field can be parsed."""
@dataclass(frozen=True)
class VendorProfile:
"""Per-vendor session normalization applied at open time.
Every field is a documented divergence between firmware families, not a
guess: line termination, transfer chunk sizing, the millisecond timeout
floor, and whether the family needs a settle query before it accepts I/O.
"""
name: str
read_termination: str = "\n"
write_termination: str = "\n"
chunk_size: int = 20 * 1024
timeout_ms: int = 3000
sync_on_open: bool = True # issue *CLS + *OPC? to drain and synchronize
# Registry keyed on the normalized manufacturer token from *IDN?.
_VENDOR_PROFILES: dict[str, VendorProfile] = {
"KEYSIGHT": VendorProfile("Keysight", chunk_size=1 << 20, timeout_ms=5000),
"AGILENT": VendorProfile("Keysight (Agilent)", chunk_size=1 << 20, timeout_ms=5000),
"ROHDE&SCHWARZ": VendorProfile("Rohde & Schwarz", chunk_size=1 << 20, timeout_ms=10000),
"TEKTRONIX": VendorProfile("Tektronix", chunk_size=1 << 20, timeout_ms=5000),
"NATIONAL INSTRUMENTS": VendorProfile("National Instruments", timeout_ms=3000),
}
_DEFAULT_PROFILE = VendorProfile("generic-SCPI")
def normalize_vendor(idn: str) -> str:
"""Reduce a raw *IDN? response to a registry key.
Collapses whitespace, strips a trailing corporate suffix, upper-cases, and
folds ' & ' so 'Rohde & Schwarz GmbH' and 'ROHDE&SCHWARZ' resolve alike.
"""
manufacturer = idn.split(",", 1)[0].strip().upper()
manufacturer = re.sub(r"\s+", " ", manufacturer)
manufacturer = re.sub(r"\b(INC|CORP|CORPORATION|GMBH|LTD|CO)\.?$", "", manufacturer).strip()
return manufacturer.replace(" & ", "&")
class MultiVendorResourceManager:
"""Single-backend PyVISA wrapper that applies a per-vendor session profile.
One backend, one ResourceManager, one process-wide instance. Each instrument
is probed once with *IDN?, resolved to a VendorProfile, and normalized before
the session is returned. Explicit caller overrides always beat the profile.
"""
_instance: Optional["MultiVendorResourceManager"] = None
_lock = Lock()
def __init__(self, backend: str = "@ivi", bootstrap_timeout_ms: int = 3000) -> None:
try:
self._rm = pyvisa.ResourceManager(backend)
except Exception as exc: # backend library missing or misresolved
raise RuntimeError(
f"VISA backend {backend!r} failed to load; run 'pyvisa-info' in "
f"this exact environment to confirm the library resolves here."
) from exc
self._backend = backend
self._bootstrap_timeout_ms = bootstrap_timeout_ms
logger.info("VISA backend %s initialized", backend)
@classmethod
def instance(cls, **kwargs) -> "MultiVendorResourceManager":
"""Process-wide singleton — never construct a second RM per host."""
with cls._lock:
if cls._instance is None:
cls._instance = cls(**kwargs)
return cls._instance
@staticmethod
def validate_resource_string(resource: str) -> str:
cleaned = resource.strip()
if not _RESOURCE_RE.match(cleaned):
raise ResourceStringError(
f"Malformed VISA resource string: {resource!r}. Expected an "
f"interface prefix, '::'-separated address, and INSTR/SOCKET/RAW."
)
return cleaned
def resolve_profile(self, idn: str) -> VendorProfile:
key = normalize_vendor(idn)
profile = _VENDOR_PROFILES.get(key)
if profile is None:
logger.warning(
"No vendor profile for manufacturer %r (from IDN %r); "
"falling back to generic-SCPI defaults.", key, idn.strip()
)
return _DEFAULT_PROFILE
return profile
def open(
self,
resource: str,
*,
timeout_ms: Optional[int] = None,
chunk_size: Optional[int] = None,
) -> MessageBasedResource:
"""Open one instrument, resolve its vendor profile, and normalize the session.
Opens with a conservative bootstrap configuration, reads *IDN? once,
applies the resolved profile, and synchronizes. Caller-supplied
`timeout_ms` / `chunk_size` take precedence over the profile values.
"""
cleaned = self.validate_resource_string(resource)
try:
session: MessageBasedResource = self._rm.open_resource(cleaned)
except VisaIOError as exc:
raise RuntimeError(f"open_resource failed for {cleaned}: {exc}") from exc
try:
# Bootstrap: minimal config sufficient to read a clean *IDN?.
session.timeout = self._bootstrap_timeout_ms
session.read_termination = "\n"
session.write_termination = "\n"
idn = session.query("*IDN?").strip()
if idn.count(",") < 3:
raise VendorResolutionError(
f"{cleaned} returned a malformed *IDN? ({idn!r}); "
f"cannot resolve a vendor profile."
)
profile = self.resolve_profile(idn)
session.read_termination = profile.read_termination
session.write_termination = profile.write_termination
session.chunk_size = chunk_size if chunk_size is not None else profile.chunk_size
session.timeout = timeout_ms if timeout_ms is not None else profile.timeout_ms
if profile.sync_on_open:
session.write("*CLS")
session.query("*OPC?")
logger.info(
"Opened %s as %s [term=%r chunk=%dB timeout=%dms]",
cleaned, profile.name, profile.read_termination,
session.chunk_size, session.timeout,
)
return session
except Exception:
session.close() # never leak a half-configured handle
raise
Validation: Confirming Each Instrument Got Its Profile
The load-bearing behavior is that the correct profile is applied to the correct instrument, so verification centers on the resolution step, not on raw connectivity. Two checks catch nearly every structural regression.
First, unit-test normalize_vendor and resolve_profile against the exact *IDN? strings your fleet emits — pull them from the instruments once and freeze them as fixtures, because a firmware update occasionally rewrites the manufacturer field:
def test_vendor_resolution() -> None:
rm = MultiVendorResourceManager.__new__(MultiVendorResourceManager) # no backend
assert normalize_vendor("Rohde&Schwarz,FSV3013,1330.5000k03,2.30") == "ROHDE&SCHWARZ"
assert rm.resolve_profile("KEYSIGHT TECHNOLOGIES,B2902A,MY5,4.0").timeout_ms == 5000
assert rm.resolve_profile("Agilent Technologies,33220A,MY4,2.0").name.startswith("Keysight")
assert rm.resolve_profile("Contec,XYZ,001,1.0").name == "generic-SCPI" # falls back
Second, against live hardware, trust the log line every open() emits — it prints the resolved vendor, termination, chunk size, and timeout for each session. A healthy multi-vendor bring-up shows one line per instrument with the profile you expect; a No vendor profile for manufacturer … warning is your signal that a device is silently running on the generic fallback. Grep the startup log for that warning before every unattended run. On the instrument side, confirm normalization is right by reading SYST:ERR? immediately after open() returns: a clean 0,"No error" means the *CLS/*OPC? settle completed and termination matched, whereas a truncated or timed-out *IDN? earlier in the sequence points straight at a termination mismatch for that vendor.
Failure Modes Specific to Multi-Vendor Structuring
These are the failures unique to resolving many vendors through one manager — distinct from the generic VI_ERROR_TMO / VI_ERROR_RSRC_BUSY faults catalogued in the parent fault matrix.
Manufacturer token drift and OEM rebrands. The registry key is only as stable as the string the firmware reports. Rebranded, relabelled, or clone instruments frequently report a manufacturer field that matches neither the vendor library nor your registry — an Agilent-era unit reporting Agilent, a house-branded DMM reporting the OEM’s name, a Rigol clone reporting something unexpected. The device then runs on the generic fallback with a possibly-wrong chunk size. Diagnose by grepping the startup log for the fallback warning and comparing the logged IDN against your registry keys; fix by adding the observed token as an alias pointing at the correct VendorProfile.
Same vendor, divergent families. Keying on manufacturer alone is too coarse when one vendor ships instruments with genuinely different transport needs — a Keysight scope wanting 1 MB blocks alongside a Keysight PSU that never transfers more than a status byte. The coarse profile is harmless for the PSU but the reverse is not: a family that needs HEADER OFF or a longer timeout gets a profile that lacks it. When this bites, extend the key to MANUFACTURER|MODEL_PREFIX for that vendor and register the model-specific profile ahead of the vendor-wide one.
Truncated binary blocks from a chunk-size mismatch. If an unknown vendor falls back to the 20 KB generic chunk but streams a multi-megabyte waveform, the definite-length block header will disagree with the bytes actually read and the parse downstream fails. This surfaces not as a VISA error but as a malformed frame in Binary & ASCII Format Parsing. Diagnose by logging the block-header length against the received byte count; fix by registering the vendor with a 1 MB chunk_size or passing an explicit chunk_size= override to open().
Bootstrap timeout too short for a busy instrument. A Rohde & Schwarz analyzer mid-self-alignment, or any instrument still running power-on self-test, will not answer the bootstrap *IDN? within the conservative 3-second bootstrap window, and the session is torn down before its real profile is ever applied. Raise bootstrap_timeout_ms for hosts that own slow-starting instruments, and treat a repeated bootstrap failure as a startup-timing fault rather than retrying blindly — persistent retries belong to Timeout Handling & Retry Logic, covered in depth for the serial case in implementing exponential backoff for serial timeout handling.
Once a session is normalized, its commands should conform to a single dialect through Command Set Standardization — see standardizing SCPI command sets across mixed hardware for the layer that sits directly on top of this manager. Under sustained polling the blocking *OPC? settle should be driven from an event loop via Async Command Queuing Systems, and any faults that survive normalization must be classified through Error Code Categorization before recovery. When instruments span isolated subnets, the network boundary the manager opens across is owned by Security Boundaries & Network Isolation.
Related
- VISA Resource Manager Setup — the parent guide covering discovery, validation, session lifecycle, and the singleton-per-host backend rule this page builds on.
- Implementing Protocol Abstraction in Python for Legacy Instruments — the layer that consumes these normalized sessions and hides command differences.
- Standardizing SCPI Command Sets Across Mixed Hardware — normalizing the dialects that ride over each vendor session.
- Securing Lab Networks for Instrument Control Systems — enforcing isolation before the manager opens across a subnet.
- PySerial Configuration & Tuning — the framing and baud concerns beneath every
ASRLsession profile.
← Back to VISA Resource Manager Setup
References
- PyVISA Documentation — backend selection, resource attributes, and session lifecycle semantics.
- IVI Foundation VISA Specification — the authoritative resource-string grammar and status-code definitions.