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.