Source code for instro.lib.discover

"""VISA instrument discovery: scan resources, query identity, match to registered drivers."""

from __future__ import annotations

import dataclasses
import enum
import warnings
from typing import Any

import pyvisa

from instro.lib.registry import driver_registry, iter_driver_entries
from instro.lib.transports.visa import SerialConfig, TimeoutConfig, VisaConfig, VisaDriver, _open_resource_manager

VISA_TRANSPORT = "visa"


[docs] @dataclasses.dataclass(frozen=True) class IdnFields: """The four comma-separated fields of an IEEE 488.2 ``*IDN?`` reply; missing fields are ``""``.""" manufacturer: str model: str serial: str firmware: str
[docs] @dataclasses.dataclass(frozen=True) class DriverMatch: """The registered driver an ``*IDN?`` reply maps to.""" category: str driver_name: str num_channels: int | None
[docs] @dataclasses.dataclass(frozen=True) class DiscoveredInstrument: """An instrument that discovery identified and matched to a registered driver. The record is enough to construct the driver (:meth:`make_driver`) or to emit the ``driver`` block of an instro JSON config (:meth:`config_block`), so scripts and fixtures don't re-derive either from the resource string. Attributes: resource: Address the driver opens: a VISA resource string, or for other transports whatever the driver's constructor takes (an NI-DAQmx device name, an MCC serial number, ...). idn: Identity reply the match was made from (the raw ``*IDN?`` string for SCPI instruments). category: Instrument category (``"psu"``, ``"dmm"``, ``"daq"``, ...). driver_name: Key of the matched driver in the category's registry (:func:`~instro.lib.registry.driver_registry`), e.g. ``"BK9115"``. num_channels: Programmable channel count where the category tracks it, else ``None``. transport: ``"visa"`` for SCPI-over-VISA instruments. Discovery providers for vendor SDKs set their own token; only ``"visa"`` records can build a :class:`VisaConfig`. backend: pyvisa backend the scan was asked for (``None`` for the default with fallback), so :meth:`visa_config` resolves it the same way the scan did. serial_config: Serial settings a serial (``ASRL``) instrument answered with, else ``None``. Example:: report = scan_visa_resources() psu = next(i for i in report.instruments if i.category == "psu") instro_psu = InstroPSU(name="psu", driver=psu.make_driver(), num_channels=psu.num_channels) """ resource: str idn: str category: str driver_name: str num_channels: int | None = None transport: str = VISA_TRANSPORT backend: str | None = None serial_config: SerialConfig | None = None # Identified by `resource`, not used as a set member or dict key; declaring this keeps a # record with a (mutable, unhashable) SerialConfig behaving like one without. __hash__ = None # type: ignore[assignment] def __post_init__(self) -> None: """Copy ``serial_config`` so the record owns it; the caller may keep mutating the object it passed in.""" if self.serial_config is not None: object.__setattr__(self, "serial_config", dataclasses.replace(self.serial_config)) @property def driver_class_name(self) -> str: """Deprecated alias of :attr:`driver_name`.""" return self.driver_name
[docs] def visa_config(self) -> VisaConfig: """The :class:`VisaConfig` that reaches this instrument, carrying the backend and serial settings it answered with. Raises: ValueError: the instrument is not on the VISA transport. """ if self.transport != VISA_TRANSPORT: raise ValueError(f"{self.resource} is on the {self.transport!r} transport, not VISA") # A copy, so adjusting the returned config cannot reach back into this frozen record. serial = dataclasses.replace(self.serial_config) if self.serial_config is not None else SerialConfig() return VisaConfig(visa_resource=self.resource, visa_backend=self.backend, serial_config=serial)
[docs] def driver_class(self) -> type: """The matched driver class, loaded from the category's driver registry on demand. Raises: KeyError: ``driver_name`` is not registered for ``category``. """ try: entry = driver_registry(self.category)[self.driver_name] except KeyError: raise KeyError(f"no driver named {self.driver_name!r} registered for category {self.category!r}") from None return entry.load()
[docs] def make_driver(self) -> Any: """A new, unopened driver for this instrument. VISA instruments get a :class:`VisaConfig`; drivers on other transports receive :attr:`resource` directly, which is the ``device_id`` convention the DAQ vendor drivers use. """ cls = self.driver_class() if self.transport == VISA_TRANSPORT: return cls(self.visa_config()) return cls(self.resource)
[docs] def config_block(self) -> dict[str, Any]: """The ``driver`` block of an instro JSON config for this instrument. ``num_channels`` is included when discovery knows it, which is exactly the set of categories whose driver config requires it (psu, scope, awg). Raises: ValueError: the instrument is not on the VISA transport, which is the only one with a JSON config schema today. """ if self.transport != VISA_TRANSPORT: raise ValueError(f"no JSON config schema for the {self.transport!r} transport") visa: dict[str, Any] = {"visa_resource": self.resource} if self.backend is not None: visa["visa_backend"] = self.backend if self.serial_config is not None: visa["serial_config"] = _serial_config_block(self.serial_config) block: dict[str, Any] = {"name": self.driver_name, "visa": visa} if self.num_channels is not None: block["num_channels"] = self.num_channels return block
VisaInstrumentInfo = DiscoveredInstrument """Deprecated alias of :class:`DiscoveredInstrument`.""" def _serial_config_block(serial: SerialConfig) -> dict[str, Any]: """``SerialConfig`` as the JSON config schema spells it: every field, enums by value.""" block: dict[str, Any] = {} for field in dataclasses.fields(serial): value = getattr(serial, field.name) block[field.name] = value.value if isinstance(value, enum.Enum) else value return block
[docs] @dataclasses.dataclass class VisaScanError: resource: str message: str hint: str | None = None
[docs] @dataclasses.dataclass class VisaUnrecognizedInstrument: resource: str idn: str
[docs] @dataclasses.dataclass class VisaScanResult: instruments: list[DiscoveredInstrument] unrecognized: list[VisaUnrecognizedInstrument] errors: list[VisaScanError]
[docs] def parse_idn(idn: str) -> IdnFields: """Split an ``*IDN?`` reply into its fields, tolerating missing trailing fields.""" fields = [f.strip() for f in idn.split(",")] fields += [""] * (4 - len(fields)) return IdnFields(*fields[:4])
[docs] def match_idn(idn: str) -> DriverMatch | None: """Match an ``*IDN?`` reply against every registered driver's :class:`~instro.lib.registry.IdnPattern`. Returns the first match in registry order, or ``None`` when no in-tree driver claims the identity. """ fields = parse_idn(idn) for category, driver_name, entry in iter_driver_entries(): for pattern in entry.idn_patterns: if pattern.matches(fields.manufacturer, fields.model): return DriverMatch(category=category, driver_name=driver_name, num_channels=pattern.num_channels) return None
def _classify_error_hint(exc: Exception) -> str | None: """Return an actionable hint for a known-bad scan exception, or None if there isn't one. ``VisaScanError.message`` always stays the raw ``str(exc)`` so callers that don't want this interpretation layer (e.g. a headless integration that just wants results) aren't forced into it; callers that do want it (the CLI) can prefer ``hint`` when it's present. """ if isinstance(exc, pyvisa.errors.VisaIOError) and "SYSTEM_ERROR" in str(exc): return "permission denied - check udev rules" err_str = str(exc) if "No backend available" in err_str or "PyUSB" in err_str: return "USB backend missing - install libusb" return None
[docs] def scan_visa_resources( backend: str | None = None, timeout: int = 2, *, rm: pyvisa.ResourceManager | None = None, ) -> VisaScanResult: """Scan VISA resources, query each for identity, and return matched instruments. Pass an already-open ``rm`` (e.g. one a caller opened for its own backend diagnostics) to reuse it instead of opening a second one. ``backend`` is always the backend the caller *asked for*: ``None`` for the default with ``@py`` fallback, or an explicit specifier. Each result records that requested ``backend``, not the one it resolved to, so a ``None`` stays ``None`` and the :class:`VisaConfig` and config block built from the result keep the default-then-fallback behavior instead of pinning whichever backend this machine happened to use. """ # pyvisa caches one ResourceManager per backend, so resolving here is free even when rm is given. resolved_rm, active_backend, _ = _open_resource_manager(backend) if rm is None: rm = resolved_rm with warnings.catch_warnings(): warnings.simplefilter("ignore") resources = rm.list_resources() instruments: list[DiscoveredInstrument] = [] unrecognized: list[VisaUnrecognizedInstrument] = [] errors: list[VisaScanError] = [] for resource in resources: if resource.startswith("ASRL"): continue driver = VisaDriver( VisaConfig(visa_resource=resource, timeout=TimeoutConfig(recv=timeout), visa_backend=active_backend), ) try: driver.open() idn = driver.query("*IDN?").strip() match = match_idn(idn) if match is None: unrecognized.append(VisaUnrecognizedInstrument(resource=resource, idn=idn)) else: instruments.append( DiscoveredInstrument( resource=resource, idn=idn, category=match.category, driver_name=match.driver_name, num_channels=match.num_channels, backend=backend, ) ) except Exception as e: errors.append(VisaScanError(resource=resource, message=str(e), hint=_classify_error_hint(e))) finally: driver.close() return VisaScanResult(instruments=instruments, unrecognized=unrecognized, errors=errors)