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
Deterministic vendor-to-profile dispatch inside open() Top to bottom: open the validated session, bootstrap a minimal termination and 3000 ms timeout, query *IDN? once, then normalize the manufacturer field to an upper-cased registry key. A decision tests whether the key is in the profile registry. On a hit the resolved VendorProfile is used; on a miss the conservative generic-SCPI fallback is used and a warning is logged. Both paths converge to apply read/write termination, chunk size, and timeout under the precedence caller-override, then profile, then default, followed by a *CLS and *OPC? settle before the normalized session is returned. A separate error rail shows that any failure after the handle is acquired routes to session.close() and re-raise. DETERMINISTIC VENDOR-TO-PROFILE DISPATCH INSIDE open() on error open validated session rm.open_resource(resource) bootstrap the session read_term="\n" · timeout=3000 ms query *IDN? once expect MFR,MODEL,SERIAL,FW normalize manufacturer upper · strip GmbH/Inc · fold & → key key in registry? generic-SCPI fallback 20 KB · 3000 ms · logs warning apply resolved profile read/write term · chunk_size · timeout override ▸ profile ▸ default *CLS + *OPC? settle drain & synchronize return normalized session session.close() re-raise hit miss

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.

← Back to VISA Resource Manager Setup

References