plesty.lib.traffic.visa ======================= .. py:module:: plesty.lib.traffic.visa .. autoapi-nested-parse:: VISA traffic manager for Plesty device communication. Attributes ---------- .. autoapisummary:: plesty.lib.traffic.visa._MAV plesty.lib.traffic.visa._POLL_FIRST_S plesty.lib.traffic.visa._POLL_MAX_S plesty.lib.traffic.visa._INTERFACE_QUERIES Exceptions ---------- .. autoapisummary:: plesty.lib.traffic.visa.NoMessageError Classes ------- .. autoapisummary:: plesty.lib.traffic.visa.VisaTrafficManager Functions --------- .. autoapisummary:: plesty.lib.traffic.visa._is_in_progress plesty.lib.traffic.visa._resource_parts plesty.lib.traffic.visa._interface_class plesty.lib.traffic.visa._enumeration_query plesty.lib.traffic.visa._is_complete_resource plesty.lib.traffic.visa._normalise_component plesty.lib.traffic.visa._resource_matches Module Contents --------------- .. py:data:: _MAV :value: 16 .. py:data:: _POLL_FIRST_S :value: 0.001 .. py:data:: _POLL_MAX_S :value: 0.02 .. py:exception:: NoMessageError(address: str, command: str, waited_s: float, polls: int) Bases: :py:obj:`pyvisa.errors.VisaIOError` The instrument never signalled a message, so none was requested. Deliberately a :class:`~pyvisa.errors.VisaIOError` carrying the timeout status code: every existing caller already reads a timeout as "this instrument does not answer that", and none of them need to change. What it adds is a promise a plain timeout cannot make — **nothing was left in flight**. The read was never issued, so the device holds no unsatisfied ``REQUEST_DEV_DEP_MSG_IN``. That is what lets the recovery ladder skip every rung: a clear and a reopen exist to rescue a stranded transfer, and here there is none to rescue. Record what was asked, of what, and for how long. :param address: The VISA resource the command went to. :param command: The command that went unanswered. :param waited_s: How long MAV was polled for, in seconds. :param polls: How many status reads that took. .. py:attribute:: address .. py:attribute:: command .. py:attribute:: waited_s .. py:attribute:: polls .. py:method:: __str__() -> str Say the instrument stayed silent, not that the link failed. .. py:function:: _is_in_progress(exc: pyvisa.errors.VisaIOError) -> bool Whether *exc* says the session still has a transfer in flight. :param exc: The error a VISA operation raised. :returns: ``True`` for ``VI_ERROR_IN_PROGRESS``. Matched on the numeric code rather than the message, which is localised by some backends. .. py:class:: VisaTrafficManager(address: str, timeout: int = 5000, write_termination='\n', read_termination='\n', baud_rate: int | None = None) Bases: :py:obj:`plesty.lib.traffic.TrafficManager` A TrafficManager implementation for managing VISA communication. This class provides methods to open, close, and communicate with VISA instruments. Initialize with VISA address, timeout, and communication parameters. .. py:attribute:: DISCOVERY_TIMEOUT_S :type: float :value: 10.0 .. py:attribute:: OPEN_TIMEOUT_S :type: float :value: 15.0 .. py:attribute:: write_termination :value: Multiline-String .. raw:: html
Show Value .. code-block:: python """ """ .. raw:: html
.. py:attribute:: read_termination :value: Multiline-String .. raw:: html
Show Value .. code-block:: python """ """ .. raw:: html
.. py:attribute:: baud_rate :value: None .. py:attribute:: rm :value: None .. py:attribute:: recovery_enabled :value: True .. py:attribute:: stb_gated_queries :value: True .. py:attribute:: _mav_proven :type: bool | None :value: None .. py:attribute:: _opened_in_progress :value: False .. py:attribute:: _ever_answered :value: False .. py:method:: discover_instrument(address: str) -> list[str] Resolve *address* to a full VISA resource string. Discovery is skipped entirely when the address is already complete — there is nothing to look up, and every enumeration is a chance to hang. Otherwise the scan is **filtered to the address's own interface**. An unfiltered ``list_resources()`` probes every ASRL, GPIB and TCPIP resource on the machine, including serial ports another device server already holds open; that call blocks inside the native VISA library and does not deliver ``KeyboardInterrupt``, so the process can only be killed. A USB instrument is found by asking for USB instruments. The scan also runs under a watchdog, so a wedged device on the bus turns into an actionable error rather than a frozen process. :param address: The VISA resource address to resolve. May be a complete resource string or a partial one such as ``"USB::0x1313::0x8078::INSTR"``. :returns: every failure raises, so a caller that gets a value has a resource to open. The first entry is the full, serial-bearing string and is logged, so it can be passed straight back as ``--address`` to skip discovery on the next run. :rtype: The matching resource strings, most specific first. Never empty :raises RuntimeError: If the bus was scanned but nothing matched — the address is wrong or the instrument is not connected — or if the VISA backend itself could not be scanned. :raises TimeoutError: If enumeration hung, which means a device on the bus is wedged. Kept distinct from "not found", because a wedged bus and a wrong address need completely different responses. .. py:method:: _list_resources(query: str) -> list[str] Enumerate resources matching *query*, under a watchdog. ``list_resources`` runs in native code: when a device on the bus is wedged it can block forever, and no Python-level interrupt reaches it. Running it on a daemon thread means the caller gets a diagnosable timeout instead, and the interpreter can still exit. :param query: A VISA resource query such as ``"USB?*::INSTR"``. :returns: The resource strings found. :raises TimeoutError: If enumeration did not finish within :attr:`DISCOVERY_TIMEOUT_S`. .. py:method:: _load_resource_manager() Load the VISA Resource Manager, attempting to find the correct DLL on Windows. .. py:method:: _open() Connect to the VISA instrument at the specified address. .. py:method:: _clear_on_open() -> None Clear the device once the session is open, before any command. A fresh USBTMC session inherits whatever state the previous one left: on the lab PM100D the *first* query after every open timed out and then succeeded on the retry that follows a device clear — reproducibly, for ``*IDN?`` and for the first parameter read alike. Clearing here costs one control transfer and removes a five-second stall from every connect, instead of leaving the recovery ladder to absorb it and hide that it is happening. Best effort: an instrument that does not support the clear is no worse off than before. One failure here is not best-effort news at all, and is reported as such. ``VI_ERROR_IN_PROGRESS`` on a session opened moments ago cannot describe anything this process did — a fresh session has issued no operation to still be in progress. It means the *instrument* was already holding a stranded transfer when the session opened, and that state outlives the session, the process and a driver-level re-enumeration. It is the earliest and clearest evidence available that what follows will be a wall of timeouts, and it is worth more than the wall. .. py:method:: _note_open_in_progress(exc: pyvisa.errors.VisaIOError) -> None Record that the clear-on-open found a transfer in flight. This was previously reported at error level as a finished diagnosis: the instrument was wedged before the session existed, every command would time out, and only a power cycle would help. That reading does not survive contact with the logs. On one PM101R it was the opening state of **ten of twelve** consecutive starts, and connect then succeeded on all twelve — identity, sensor query and limit reads all answered normally. A message that tells someone to unplug a working instrument, at error level and with certainty, spends the credibility that the genuine remedy needs. So the condition is recorded rather than interpreted. It is a real observation and worth keeping — a wedged instrument does present this way — but it is not evidence on its own, because a healthy one presents this way too. :meth:`_diagnose_if_never_answered` states the diagnosis later, once a command has actually failed and nothing on the session has ever been answered, which is when the two cases finally separate. :param exc: The ``VI_ERROR_IN_PROGRESS`` the clear raised. .. py:method:: _diagnose_if_never_answered(exc: pyvisa.errors.VisaIOError) -> None Report a pre-existing wedge, once there is evidence for one. Two facts together mean the instrument was unreachable before this session opened, and neither means it alone: the clear-on-open found a transfer in flight, *and* nothing on this session has ever been answered. A session that has answered even once was talking to a live instrument, so a later failure is an ordinary fault for the recovery ladder. A session that has never been answered and opened onto a transfer it did not create was talking to a wedged one from the start. Said at error level because at that point it is a diagnosis rather than a symptom, and the remedy is spelled out because the two obvious attempts both fail: restarting rebuilds a session against the same stuck endpoint, and a driver-level re-enumeration (Windows: ``Disable-PnpDevice``/``Enable-PnpDevice``, ``pnputil /restart-device``) resets the port without ever dropping bus power, so a transfer held in the instrument's own firmware rides straight through it. Both were tried on a wedged PM101R and both failed. :param exc: The error the failing command raised. .. py:method:: _open_resource(address: str) Open a VISA session on *address*, under a watchdog. Opening a USBTMC session is not a local operation: it talks to the instrument, so a wedged device blocks here exactly as it blocks an enumeration — inside native code no Python interrupt can reach. This is the last unguarded call on the connect path. :param address: A full VISA resource string. :returns: The opened pyvisa resource. :raises RuntimeError: If the resource manager is not loaded. :raises TimeoutError: If opening did not complete within :attr:`OPEN_TIMEOUT_S`. .. py:method:: _close() Close the VISA connection. Clearing first discards any buffered response so the next session does not inherit it — but an instrument that is not answering can refuse the clear, and a session that cannot be cleared still has to be closed. So the clear is best effort, and the close goes through :meth:`_release_session`, which falls back to the underlying handle when the session itself refuses. A device server shutting down on a wedged instrument otherwise leaves the interface claimed until the process dies. .. py:method:: _send_command(command: str, timeout=None, mode=None) -> str | bool Send a command to the VISA instrument and return the response. A :class:`~pyvisa.errors.VisaIOError` triggers in-place recovery before the error ever reaches the caller: first a buffer discard and a USBTMC device clear (which unwedges most stalled interfaces), then a full session reopen — the command is retried after each rung. Plain SCPI writes and queries are idempotent, so the retries are safe. Only a third consecutive failure propagates. Physical intervention (replugging, power-cycling) must never be the documented remedy for a wedged instrument. ``VI_ERROR_IN_PROGRESS`` skips straight to the reopen. It means the session still has a transfer in flight — a read the instrument never answered and the backend never abandoned — and *every* operation on such a session reports it, the clear included. Retrying there cannot succeed: on a PM101R with no sensor head it turned one timeout into a ladder that reported the wrong fault at every rung and burned a second timeout to learn nothing. Only a new session clears the condition. Set :attr:`recovery_enabled` to ``False`` around an *optional* query. MIN/MAX limits are per-parameter optional in SCPI, and an instrument that does not implement one simply never answers: escalating that to a device clear and a session reopen costs three timeouts and a teardown to learn something the instrument was entitled not to support. .. py:method:: _answered(result) Record that the instrument responded, and pass *result* through. The flag it sets is what stops a pre-existing wedge being diagnosed from a single failure: an instrument that has answered once was demonstrably reachable, so a later error is an ordinary fault rather than evidence it was stuck before this session opened. :param result: Whatever the operation returned. :returns: *result*, unchanged. .. py:method:: _discard_buffers() -> None Throw away whatever the last exchange left in the session buffers. A timed-out query leaves its answer half-arrived: the instrument may still deliver it, and the backend may still hold part of it. Either remnant becomes the reply to the *next* command — a wavelength read answered with the previous power reading, which is worse than the timeout that caused it. The discard costs nothing and runs before the clear, which reaches the instrument but not the host-side buffers. Best effort: a backend that does not implement flushing raises, and a session that cannot be flushed is no worse off than before. .. py:method:: orphan(command: str) -> bool Transmit *command* without reading its answer. :param command: A command the instrument will answer, so that the answer is the one left unread. :returns: ``True`` once transmitted. .. py:method:: _effective_timeout(timeout: int | None) -> int Return the timeout to use, honouring any cap. :param timeout: A per-call timeout, or ``None`` for the session default. .. py:method:: _guarded(mode: str, command: str) Run one VISA operation. This carried a watchdog: a timer that fired at twice the session timeout and issued a device clear from its own thread, on the theory that ``viRead`` can hang in native code past its own timeout and an asynchronous clear is the documented way to abort it. It was removed because it cannot work. The watchdog only fires while the calling thread is still inside the read — that is the only situation it exists for — and VISA refuses a clear on a session another thread holds. Against one instrument it fired five times in a day and failed with ``VI_ERROR_RSRC_LOCKED`` every time, leaving a failed clear pending at the device; twice that took a USB re-enumeration to undo. It reliably produced the wedge it was meant to escape. What does work is the recovery ladder in :meth:`_send_command`: its clear runs on the calling thread, after the read has returned, and it has rescued every stall observed so far. .. py:method:: _can_gate() -> bool Whether this session can be asked for a status byte before reading. Gating splits ``query`` into its three parts, so a resource that only offers the combined call cannot be gated. Checked before the write rather than caught after it: committing the command and *then* discovering the session cannot report status would leave exactly the stranded transfer this exists to prevent. :returns: ``True`` when write, read and a status read are all available. A resource without them keeps the unguarded path, which is what every interface did before this guard existed. .. py:method:: _query_when_ready(command: str) -> str Write *command*, then read only once the instrument says it can answer. This exists because of what an abandoned read costs. A USBTMC query is two exchanges: the command out, then ``REQUEST_DEV_DEP_MSG_IN`` and a bulk-in read. An instrument is entitled to stay silent — an undefined header is answered with ``-113`` in the error queue and no response at all — and when it does, the read times out and we walk away while the device still holds the request we made. Nothing in the recovery ladder retracts it: ``viClear`` cannot be queued behind a pending bulk-in and so reports ``VI_ERROR_IN_PROGRESS``, and reopening the session replaces a host-side handle without touching the device. Five such queries in one session stopped a PM101R and a PM400 servicing USB-TMC at all, and only a power cycle brought them back. Polling MAV first removes the cause rather than recovering from it: if the instrument has no message, no read is issued, and there is nothing to strand. Measured on a PM101R with an S155C, ``MEAS:ENER?`` — one of the five that wedged it — became a four-second non-event, the status byte sitting at ``0x04`` (EAV: error queued, no message) across forty polls, with the next three queries answering in single-digit milliseconds. The guard will not fire until MAV has been seen at least once on this session. A status byte that never sets MAV is ambiguous — the instrument may have nothing to say, or may simply not maintain the bit — and only attempting the read distinguishes them. So the first such query falls back to reading, which is exactly today's behaviour and no worse; if that read succeeds, the instrument is not using MAV and gating switches off for the rest of the session. :param command: The query to send. :returns: The instrument's answer. :raises NoMessageError: If MAV never set within the session timeout and the bit is known to work — the instrument is not going to answer, and nothing was left in flight. .. py:method:: _reopen() -> None Tear down the wedged session and open a fresh one — no discovery. Deliberately avoids ``_open``: its ``list_resources()`` discovery enumerates every USBTMC device and is exactly the call that can hang indefinitely while one of them is wedged. The address is already known-good from the original open, so the fresh session targets it directly — but through the same watchdog ``_open`` uses, because opening talks to the instrument and this rung runs precisely when the instrument is not answering. .. py:method:: _release_session() -> None Give up the current session, whether or not it consents. ``close()`` is itself an operation on the session, so the one session that most needs closing — one with a transfer in flight — is the one that refuses. Dropping the reference there does not free it: the backend keeps the handle, the instrument keeps the interface, and the next reopen adds a *second* live session to the same device. Two rungs of the ladder left a PM101R holding three, at which point ``*IDN?`` stopped answering on all of them. So a refused close falls back to closing the underlying VISA handle directly, which does not go through the session and so cannot be blocked by its state. .. py:data:: _INTERFACE_QUERIES :type: dict[str, str] .. py:function:: _resource_parts(address: str) -> list[str] Split a VISA resource string into its non-empty ``::`` components. .. py:function:: _interface_class(token: str) -> str Return the interface class of a resource token, without its board index. ``"USB0"`` and ``"USB"`` both yield ``"USB"``, which is what makes a shipped default address comparable with an enumerated resource. .. py:function:: _enumeration_query(address: str) -> str Return the narrowest enumeration query that can still match *address*. Falls back to ``"?*::INSTR"`` for an interface we do not recognise, which is still narrower than an unfiltered scan. .. py:function:: _is_complete_resource(address: str) -> bool Return ``True`` when *address* is already a full resource string. A complete address carries a board index (``USB0``, not ``USB``) and, for USB, the instrument serial. Such an address can be opened directly, so no enumeration is needed — and the enumeration that is skipped is the call that hangs on a wedged bus. .. py:function:: _normalise_component(value: str) -> str Normalise one address component for comparison. Vendor and product ids appear as ``0x1313`` in a shipped default and as ``0x1313`` or its decimal form in an enumerated resource depending on the backend, so numbers are compared by value and everything else casefolded. .. py:function:: _resource_matches(requested: str, candidate: str) -> bool Return ``True`` when *candidate* is the resource *requested* asks for. Compares component by component rather than by substring. The substring test cannot work for the shipped defaults: ``"USB::0x1313::0x8078::INSTR"`` is not a substring of ``"USB0::0x1313::0x8078::P0001234::INSTR"`` — the board index and the serial segment both break it — so every VISA module's default address failed to match a device that was plainly connected. A requested component sequence matches when it appears in order in the candidate, which lets a serial-less address match the serial-bearing resource while still rejecting a different vendor or product id.