Skip to content

Add support for NI GPIB-ENET/100 bridge.#587

Open
bytewarrior wants to merge 51 commits into
pyvisa:mainfrom
bytewarrior:feature/ni-gpib-enet100
Open

Add support for NI GPIB-ENET/100 bridge.#587
bytewarrior wants to merge 51 commits into
pyvisa:mainfrom
bytewarrior:feature/ni-gpib-enet100

Conversation

@bytewarrior

Copy link
Copy Markdown

Summary

Adds a pure-Python session driver for the NI GPIB-ENET/100 Ethernet-to-GPIB bridge, modeled after the existing Prologix support. Once the companion pyvisa changes are merged, one can

rm = pyvisa.ResourceManager("@py")
rm.open_resource("NI-ENET100-TCPIP0::192.0.2.42::INTFC")
inst = rm.open_resource("GPIB0::16::INSTR")  # dispatched through the bridge
print(inst.query("*IDN?"))

with no additional dependencies (stdlib socket / struct / threading only).

Depends on

pyvisa#980 – Add InterfaceType.ni_enet100_tcpip, NIEnet100TCPIPIntfc rname class and Resource subclass.

Until that PR is merged, from pyvisa_py import nienet100 raises ImportError, which highlevel.py swallows at debug level (mirrors the vicp pattern). All NIENET100 tests skip cleanly in that case, so this PR remains importable today; it just stays inert.

What's added

  • Wire protocol (pyvisa_py/protocols/nienet100.py)
  • Discovery (pyvisa_py/protocols/nienet100_discovery.py) — UDP broadcast on the local broadcast domain.
  • Session layer (pyvisa_py/nienet100.py) — NIEnet100TCPIPIntfcSession for NI-ENET100-TCPIP::INTFC, NIEnet100InstrSession for GPIB::INSTR resources that route through a registered bridge.
  • Dispatch hook — chains in front of gpib.py's GPIBSessionDispatch and falls back to it for non-NIENET100 boards. Coexists with Prologix: load order is prologix → gpib → nienet100, dispatch order at open_resource is NIENET100 → Prologix → linux-gpib. Users wire each bridge to a distinct board number.
  • Module wiring in pyvisa_py/highlevel.py (one new try/except block).
  • Tests — offline unit tests for the wire layer, discovery, and SRQ verbs; assisted (hardware-gated) tests for the wire layer and full pyvisa-stack session layer.

Testing

Offline (always runs, no hardware needed):

  • pyvisa_py/testsuite/test_nienet100.py (~25 tests) — wire framing, status header parsing, open/close sequences, SRQ verbs.
  • pyvisa_py/testsuite/test_nienet100_discovery.py — discovery frame parsers + UDP loop with mocked sockets.

Hardware-gated (pyvisa_py/testsuite/nienet100_assisted_tests/) — opt-in via env vars:

PYVISA_TEST_NIENET100_HOST  # IP or hostname of the bridge
PYVISA_TEST_GPIB_PAD        # primary address of an instrument on the bus
PYVISA_TEST_GPIB_SAD        # (optional) secondary address
PYVISA_TEST_IDN_VENDOR      # (optional) substring expected in *IDN?
PYVISA_TEST_GPIB_TERM       # (optional) write/read termination — defaults to \n

Without PYVISA_TEST_NIENET100_HOST set, every assisted test skips cleanly. Verified locally against a real GPIB-ENET/100.

Out of scope / known limitations

These are documented in the code and listed here so reviewers don't have to chase them:

  • Six of the seven gpib_control_ren modes return error_nonsupported_operation — only VI_GPIB_REN_DEASSERT_GTL (which is by far the common case) is wired. The other six need an ibsre verb whose wire frame is not yet reverse-engineered. Prologix exposes none of these modes, so this is already a strict improvement.
  • No SRQ event delivery to pyvisa's event modelibwait is implemented at the wire layer but the bridge to pyvisa's enable_event/wait_on_event machinery is not.
  • ibrsp with cnt != 1 raises NIEnet100ProtocolError. The wire spec lets the bridge cram multi-byte STB blocks into a single response; in practice every device we've seen sends a single STB byte. Will relax when an instrument shows otherwise.
  • Multi-chunk ibrd data fragmentation — the parser handles it (accumulates into payload across data chunks until END or final-status), but has not been validated against very large device responses.
  • *IDN? instrument tests assume \n termination because pyvisa's \r\n default chokes several older GPIB instruments. The PYVISA_TEST_GPIB_TERM env var overrides.

The bridge itself has two intrinsic quirks worth noting for users:

  • Built-in timeout minimum ~3 s. Per-call tmo_ms in ibrd does not lower this floor — short pyvisa timeouts (e.g. inst.timeout = 200) surface as the configured error_timeout but after ~3 s wall-clock, not 200 ms. The socket-level timeout ceiling is sized to accommodate.
  • Limited concurrent sessions. Rapid open/close cycles can put the bridge into a state where it stops accepting new TCP connections for ~60 s. Practical workaround: serialize bridge sessions, or wait/power-cycle.

Reviewer guidance

The commit history is structured for sequential review — each commit is self-contained and the rough phases are:

  1. Wire-protocol primitives (chunks, status headers, command framing).
  2. Open / close sequences and Device-I/O verbs.
  3. Session-layer integration into pyvisa-py (pyvisa_py/nienet100.py, dispatch hook, module loader).
  4. SRQ verbs and lazy wait/control socket lifecycle.
  5. Discovery (frame primitives, UDP loop, list_resources wiring).
  6. Offline tests.
  7. Hardware-gated tests.
  8. Wire-level fixes uncovered by hardware testing (ibrsp combined chunk, ibrd no-END path, IbcTMO rejection, status-header chunk wrapping).
  9. Session-layer fixes uncovered by full-stack hardware testing (resource-string mapping, board-key type, timeout handling, test robustness).

The protocol was reverse-engineered from wire captures and the gpibhlpr.dll decompilate; key wire-layer details (status-header chunking, ibrsp combined-chunk format, ibrd two-chunk model, tmo_ms semantics) are documented inline at their use sites.

  • Executed ruff with no errors
  • The change is fully covered by automated unit tests

still open, will continue after initial feedback:

  • Documented in docs/ as appropriate
  • Added an entry to the CHANGES file

bytewarrior and others added 30 commits May 31, 2026 08:43
New module pyvisa_py/protocols/nienet100.py with constants and the
12-byte command/status frame primitives. Pure encoding/decoding, no
sockets yet — those follow in later commits.

Covers:
- NI-488.2 ibsta/iberr bits, TMO code table, seconds_to_tmo_code helper
- pack_command / parse_status_header / parse_chunk_header
- Exception hierarchy (NIEnet100Error / IOError / ProtocolError)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
read_chunks_until_end consumes the data-chunk stream that follows the
preliminary status header of a read response, returning concatenated
payload at the END marker. Out-of-band signal chunks (flags=2) are
logged and skipped per the defensive-handling note in the wire spec.

read_one_data_chunk covers verbs whose response is a single fixed-size
chunk and may omit the END marker (ibrsp returns 1 STB byte).

Both helpers take a read_exactly callable so the layer stays socket-free
and is straightforward to unit-test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EnetConnection opens the main socket (port 5000) and the mandatory
companion socket (port 5015) and sends the 'U 02' companion hello as
required by every GPIB-ENET/100 firmware shipped in the last ~20 years.
Wait (5003) and control (5005) sockets are deliberately not opened
here; they are only needed for ibwait and async notify-off paths and
will be added in a later step.

The class exposes minimal recv/send helpers (recv_main_exactly,
send_main, read_status_main, transact_main) so subsequent commits can
build verb-level operations on top without touching socket internals.
close() is idempotent and safe to call before open().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
open_gpib_session sends the seven-frame open sequence (Frames A through
G of the wire spec) on the main socket: SetConfig with the SC bit,
PPC mode, board-flags SetConfig, online, event-queue depth, bracket
open, and the defensive notify-off. After it returns the box is ready
for Device-I/O against the requested primary/secondary address.

close_gpib_session sends the matching bracket-close frame and swallows
errors so socket cleanup always runs.

Each frame includes a comment showing the exact wire bytes per the
spec so the layout is reviewable against the reference at a glance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the minimal pyvisa-Resource API surface on top of the main
socket and the chunk reader:
- ibwrt sends the 0x62 header + raw payload in a single sendall;
  odd-length payloads are zero-padded on the wire as required.
- ibrd reads the preliminary status, the chunk stream until END, and
  the final status (the box does not take a max-count argument so the
  message is read whole; callers that need to truncate do so after).
- ibclr / ibtrg / ibloc are simple 12-byte command + status round-trips.
- ibrsp reads one data chunk; per the spec the END marker may be
  omitted so we deliberately do not try to consume it.
- set_io_timeout maps to the IbcTMO property (idx 0x03); takes a
  discrete NI-488.2 TMO code, not milliseconds.

Async verbs (ibwait, ibnotify) and board-level verbs (ibsic, ibcmd)
are deferred until the wait/control sockets land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New module pyvisa_py/nienet100.py with the INTFC half of the session
layer. The INTFC opens an EnetConnection (main + companion sockets,
companion hello) as a connectivity sentinel and registers itself in
_NIEnet100IntfcSession.boards under the resource's board number. The
GPIB dispatch hook (added in a later commit) consults that registry to
route GPIB<n>::*::INSTR resources through the appropriate bridge.

Requires pyvisa to expose InterfaceType.ni_enet100_tcpip and
rname.NIEnet100TCPIPIntfc — both will land in a separate pyvisa PR.
Until then this module raises ImportError on load, which highlevel.py
swallows the same way it does for missing optional backends (e.g.
pyvicp). Users opening NIENET100 resources before the pyvisa change
ships get a clean "No class registered" error from the dispatcher.

Reformatting of the existing protocols/nienet100.py to ruff-format
output rides along since both files were touched together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NIEnet100InstrSession is instantiated by the GPIB dispatch hook (next
commit) for GPIB<n>::<pad>[::<sad>]::INSTR resources when board n was
previously bound by a NIENET100-TCPIP::INTFC session.

Each INSTR owns its own EnetConnection (main + companion sockets) and
its own bracket (Frames A-G). The wire spec recommends per-resource
TCP sessions over multi-PAD bracket switching on a shared connection
and that is what we do: no cross-resource locks, no shared interface
state. Multiple instruments on the same bridge each pay one extra TCP
session to the box but gain simplicity and concurrency.

Implements write/read/clear/assert_trigger/read_stb/gpib_control_ren
(go-to-local only, the other REN ops need board-level verbs that have
not landed yet). The timeout setter maps pyvisa milliseconds to a
discrete NI-488.2 TMO code for the wire layer and a small-margin
socket timeout so the box always reports its own timeout first.

_map_iberr_to_status translates the bridge's iberr field into the
closest pyvisa StatusCode so the dispatcher returns meaningful errors
to user code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hook the bridge into the (gpib, INSTR) dispatch table by registering a
wrapping dispatcher in nienet100.py. Boards bound to a NIENET100-TCPIP
INTFC route to NIEnet100InstrSession; everything else delegates to the
previously registered class (typically GPIBSessionDispatch from
gpib.py) so Prologix and linux-gpib paths keep working unchanged.

Putting the hook in nienet100.py instead of gpib.py lets it work on
systems where gpib.py fails to import because neither linux-gpib nor
gpib-ctypes is installed — exactly the configuration most GPIB-ENET/100
users run.

The prior registration is popped before our @Session.register call so
the "already registered, overwriting" warning does not fire on every
import; the overwrite is deliberate.

highlevel.py picks up the new module via the same try/except pattern
used for the other backends so an outdated pyvisa (missing the
upstream InterfaceType.ni_enet100_tcpip change) silently disables the
NIENET100 path instead of breaking pyvisa-py load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
34 tests covering pyvisa_py.protocols.nienet100 without network:
- Frame pack/unpack, status header and chunk header parsing.
- Chunk reader: concatenation across data chunks, END marker,
  defensive tolerance of signal chunks (flags=2), and the protocol
  errors raised for unknown flags or END-with-payload.
- read_one_data_chunk for ibrsp-style single-chunk responses.
- IP-to-u32 helper and seconds-to-TMO-code rounding.
- Device verbs (ibwrt, ibrd, ibrsp, ibclr, ibtrg, ibloc,
  set_io_timeout) driven against an in-memory scripted peer over
  socket.socketpair, asserting exact wire bytes on the send side and
  scripted box responses on the recv side. Includes the iberr->raise
  error path.

Hardware-gated integration tests against a real bridge are not
included here and will land separately once a test fixture exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EnetConnection grows two new optional sockets and the lazy helpers
that own their setup:

- ensure_wait_socket() opens port 5003 and sends the device-mode
  async-register frame ('U 01' with flags=2 carrying the main socket's
  getsockname) followed by the 'P 10 01' online re-confirm. After this
  the box accepts ibwait polls from the wait socket and matches SRQs
  against the main session.
- ensure_control_socket() opens port 5005 with no setup frames; the
  first 'O' verb sent there carries whatever the box needs.

Both helpers are idempotent. close() and set_socket_timeout() are
extended to walk all four sockets so existing call sites keep working
unchanged. The class is documented as not thread-safe — concurrent
ibwait polling and synchronous Device-I/O on the same instance would
interleave bytes on the sockets.

ibwait, ibsic, and notify-off-async land in the next commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ibwait(mask) issues one polling round-trip on the wait socket (lazily
opened on first call) and returns the box's sta. The caller decides
how to react: STA_RQS means an SRQ is pending (acknowledge with
ibrsp); STA_TIMO means the box's IbcTMO fired without an event. The
polling loop itself is left to the caller — single-threaded adapters
do fine with 0.2-0.5 s sleep intervals per the wire spec.

Wire layout: 54 00 [htons(mask):2] 00*8.

A higher-level wait-for-srq helper on the pyvisa-py session layer can
be added when pyvisa-py grows a real event mechanism; until then user
code can call session.interface.ibwait(...) directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new verbs on the control socket (port 5005), both using the 'O'
family layout (IP-before-port, unlike 'U' verbs which put port first):

- ibsic pulses the GPIB IFC line and is useful as a board-level reset
  between tests or to recover from a hung instrument.
- notify_off_async_device deregisters the async event channel that
  ensure_wait_socket previously registered.

close() is extended to call notify_off_async_device automatically when
the wait socket was opened, so the box does not keep the registration
around between sessions. The cleanup is best-effort: errors during it
are logged and swallowed so socket teardown always runs.

A shared _pack_o_verb helper captures the wire layout so future
'O' verbs (ibwait re-arm, notify-off-board) can reuse it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 new tests covering the wait/control socket lifecycle, ibwait, ibsic,
notify_off_async_device, and the close-time notify-off cleanup. All
drive scripted peers over socket.socketpair so the suite still does no
real network I/O. _make_bound_connection grows wait/control = None
defaults so existing tests keep working; _make_empty_connection is the
new helper for lifecycle tests that monkey-patch _connect.

Total test count goes from 34 to 44, all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New module pyvisa_py/protocols/nienet100_discovery.py with the pack
and parse halves of the bridge's UDP discovery protocol. The 184-byte
frame format is captured here once with named offsets so the layout
matches the wire spec by inspection (every byte position is referenced
via a named constant rather than a magic number).

BoxInfo is a frozen dataclass carrying the parsed IP/MAC/serial/model/
hostname/subnet/gateway/comment plus the echoed nonce and the response
op-code. The is_busy property covers the OP_BUSY (0x09) reply variant
so callers can distinguish a found-but-busy box from a found-and-idle
one without poking at the op-code directly.

parse_discovery_response returns None (rather than raising) for any
frame that fails magic/length/op-code validation. This matters because
the discovery socket is a broadcast listener that will see arbitrary
foreign UDP datagrams from other devices on the LAN — silently
discarding them is the correct behavior.

The UDP socket loop (discover()) and the integration with
NIEnet100TCPIPIntfcSession.list_resources() land in the next commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
discover() opens a single UDP socket bound to ('', port) with
SO_REUSEADDR and SO_BROADCAST set, fires one 184-byte probe at the
configured broadcast (or unicast) destination, and reads replies until
the caller-supplied timeout elapses. Replies are parsed via
parse_discovery_response so foreign UDP traffic and the bridge's own
echo of our outgoing probe are silently filtered out. Results are
deduplicated by MAC (boxes commonly answer once per host network
interface) and returned sorted by IP for stable output.

The recv loop tolerates Windows' WSAECONNRESET behavior: when a prior
sendto generates an ICMP port-unreachable response, Windows surfaces
it on the next recvfrom as ConnectionResetError. Treating that as a
single skipped packet (rather than scan-aborting) keeps unicast scans
of partially-reachable subnets useful.

Port parameter doubles as the destination port (where the probe is
sent) and the bind port (where replies arrive). In production both
are 44515 (broadcast) or 44516 (cross-subnet unicast); separate-port
testing happens via in-test monkey-patching in a later commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NIEnet100TCPIPIntfcSession.list_resources now calls into the discovery
helper and emits one resource string per reachable bridge on the local
broadcast domain. The board number is enumerated by discovery sort
order so that multiple bridges on the same LAN come back as
NIENET100-TCPIP0, NIENET100-TCPIP1, ... and can all be opened without
manual disambiguation.

Discovery errors (bind conflict, missing broadcast route) are caught
and surfaced as an empty list rather than propagated — list_resources
is best-effort across all pyvisa-py backends and a noisy failure here
would clutter the resource manager.

The discovery import is local to the method to keep top-level imports
focused on the TCP code paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
22 tests in a new test module mirroring the discovery module split:

- pack_discovery_request: layout, big-endian nonce, zeroing of unset
  fields (paranoid all-bytes-except-known-set must be zero check).
- parse_discovery_response happy paths: full-field round-trip, OP_BUSY
  flag, empty strings, NUL-terminator truncation past garbage bytes.
- parse_discovery_response validation: parametric coverage of wrong
  length / bad magic / wrong op-code / truly foreign datagrams.
- discover(): sorted-by-IP output, MAC dedup (default and opt-out),
  foreign-frame skip, ConnectionResetError tolerance (Windows path),
  timeout-empty path, probe destination correctness, bind-failure path.

discover() tests use unittest.mock to patch socket.socket so the suite
needs no broadcast-capable interface, no port 44515, and no hardware.

Total suite goes from 44 to 66 tests, all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New nienet100_assisted_tests/ subpackage in the testsuite, mirroring
the existing keysight_assisted_tests/ convention. Tests are gated by
environment variables and skip cleanly when no hardware is configured.

Configuration:
- PYVISA_TEST_NIENET100_HOST: bridge IP (required)
- PYVISA_TEST_GPIB_PAD: instrument primary address (required for the
  instrument-level subset)
- PYVISA_TEST_GPIB_SAD: optional secondary address
- PYVISA_TEST_IDN_VENDOR: optional substring asserted in *IDN? reply

test_wire.py drives EnetConnection directly, bypassing the pyvisa-py
session layer. That keeps it independent of the pending pyvisa upstream
change for InterfaceType.ni_enet100_tcpip — useful for first-light
validation against new hardware. Coverage:

- Discovery (broadcast and unicast/cross-subnet variants) must find the
  configured bridge.
- Main + companion socket open/close round-trip.
- Instrument fixture runs Frames A-G open + bracket close on teardown,
  even when the test body raises, so failed tests do not leave state on
  the bridge.
- *IDN? round-trip with optional vendor-substring assertion.
- ibclr / ibtrg / ibrsp accept and complete.
- Timeout configured via IbcTMO=T100ms surfaces as iberr=EABO and
  fires within the configured window (under 5 s sanity bound).
- ibwait exercises the lazy wait-socket setup + async-register +
  online-reconfirm sequence with a real bridge.

Session-layer tests (via ResourceManager('@py')) land next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test_session.py exercises the complete pyvisa-py path: ResourceManager
opens the NIENET100-TCPIP::INTFC (binding board 0 in the dispatch
table), then GPIB0::<pad>::INSTR resolves to NIEnet100InstrSession via
the wrap-dispatcher. This catches integration issues that wire-level
tests miss: attribute plumbing, error-code mapping, timeout setter
behavior, fixture-style lifecycle, and the dispatch hook itself.

Module-level pytestmark skips the whole file when pyvisa_py.nienet100
fails to import — that's the dev-machine state until the upstream
pyvisa change for InterfaceType.ni_enet100_tcpip ships. The
ImportError is caught at module load so test collection still
succeeds; the tests just report as skipped with the reason.

Fixtures are scoped so the ResourceManager and INTFC are reused across
the whole module while each test gets its own per-test INSTR session,
keeping bridge state turnover low without sharing instrument state
between tests.

Coverage: rm.list_resources discovery, INTFC board registration,
*IDN? via Resource.query, clear/read_stb/assert_trigger, timeout
mapped to StatusCode.error_timeout, and a back-to-back-query
regression guard for state leakage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the user supplies a hostname (typical NIENET100 factory default is
nienet<MAC suffix>) the discovery tests must compare against the box's
IP, not the hostname — discovery replies always carry the bridge's
dotted-quad IP. Resolve once via socket.gethostbyname and assert
against the resolved IP. Falls back to HOST as-is when DNS resolution
fails so the assertion diff is meaningful instead of a gaierror.

EnetConnection-based tests are not affected — sock.connect accepts
both hostnames and IP literals directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The unicast/cross-subnet variant of discovery (port 44516) needs the
probe source to be on a different subnet than the bridge — same-subnet
probes on 44516 see no reply because the box answers via its standard
broadcast path on 44515 (where the test socket is not listening).

Hardware first-light against a NIENET100 on the local subnet
confirmed both the broadcast discovery path and the main + companion
socket setup work as specified; only the cross-subnet test misfired
because the test environment cannot validate it. Gating that test on
PYVISA_TEST_NIENET100_CROSS_SUBNET=1 turns it into an opt-in for
testers who actually have a cross-subnet host available.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When LOGGER is at DEBUG level, send_main and recv_main_exactly emit
hex dumps of every byte that crosses the main socket. The
isEnabledFor check keeps the .hex() formatting out of the hot path
when DEBUG is not enabled, so the overhead in production stays at
one branch per call.

This is the primary diagnostic for surprises against real hardware:
run pytest with --log-cli-level=DEBUG to see the conversation, or
attach a handler to the pyvisa_py.protocols.nienet100 logger in your
own code. Wait/control/companion sockets are not yet logged —
extending coverage there is a follow-up if it becomes useful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The wire spec documents chunk flags 0 (data), 1 (END) and 2 (signal
byte) but the firmware has been observed to use additional terminal
markers (e.g. 0x0004) under conditions the spec does not enumerate.
Treating any unknown flag with length=0 as end-of-stream (with a
warning log) lets the caller's subsequent status-header read carry
the real outcome (STA_ERR + the appropriate iberr code) instead of
read_chunks_until_end raising on a flag we have not characterized.

Unknown flags carrying a non-zero length still raise: we cannot stay
frame-aligned without knowing how to consume the data, so silently
proceeding would corrupt every subsequent operation.

Updates the offline test for the unknown-flag path to reflect the new
behavior (zero-length tolerated, non-zero still rejected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The wire spec is explicit that ibwait returns synchronously and that
sta=0 means "no event matched the mask, poll again" — it is not an
error condition. The previous test asserted sta had CMPL or TIMO set,
which is only reliable when the box is configured to surface those
events deterministically.

The test is now a smoke test for the wire round-trip: any value in
the documented sta range counts as success. It still exercises the
lazy wait-socket open + the 'U 01' async-register + 'P 10 01' online
re-confirm sequence end-to-end, so wire-protocol regressions in that
setup will surface. Renamed to test_ibwait_round_trip to match the
naming convention of the other smoke tests and drop the misleading
*IDN? framing.

Synthesizing a deterministic SRQ would need instrument-side
configuration (e.g. *ESE 1; *SRE 32) that is out of scope for a
backend-level smoke test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bridge wraps every status header in the same chunk framing it uses
for ibrd/ibrsp payloads — i.e. the on-wire response for a status header
is [4-byte chunk header: flags=0 length=12] followed by [12-byte status
body]. Reading the 12 status bytes directly leaves the 4-byte chunk
header in the socket buffer, which then leaks into the next status read
and accumulates a per-operation misalignment.

The new read_status_chunk helper uses the existing single-chunk reader
to strip the wrapper and parse the body in one shot. All eight status-
read sites (read_status_main, companion hello, async-register, online
reconfirm, ibwait, ibsic, notify-off-async-device, and the two reads
inside ibrd) now go through it.

Offline test fixtures grow a _wrap_status helper so the scripted peers
emit responses in the same chunk-wrapped form the real bridge sends.
The low-level parse_status_header tests still operate on raw 12-byte
bodies — they cover the parser, not the wire framing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bridge rejects the IbcTMO property setter ('P 03') once a bracket
is open — symptom is sta=STA_ERR|CMPL on the property frame's status
response. This matches the behavior the wire spec already documents
for PAD/SAD ('P 01'/'P 02'). The in-frame tmo_ms override in the ibrd
verb (byte[4..7] = htonl(tmo_ms)) is intended for exactly this
mid-session use case, so the timeout test now uses that instead.

Drops the try/finally restore of the session timeout: with the
per-call override there is no session-state mutation to undo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ibrsp is special among the verbs: the bridge does not send the STB as
a separate data chunk after the status header. Instead it packs the
12-byte status header and the 1-byte STB into a single chunk with
length=13, with the status's cnt field set to 1 to signal "one byte
appended". The previous implementation read two chunks (status, then
STB) and tripped the length check on the 13-byte status chunk because
read_status_chunk expected exactly 12.

Updated offline test fixture mirrors the on-wire format: a single
chunk(0, 13) whose body is status_body + STB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes for the ibrd verb:

1. Change the default tmo_ms from 0 to 10 s (the new
   DEFAULT_IBRD_TMO_MS constant). On the wire, tmo_ms=0 is interpreted
   by the bridge as "do not wait" — it returns immediately with cnt=0
   and skips the END marker entirely. That is almost never what the
   caller wants; matching NI's measurement-equipment default of T10s
   gives the device time to actually respond.

2. Tolerate the no-data response path. When the bridge has no device
   data to deliver (timeout fired, or device sent nothing), it does
   not send the spec's "data chunks + END marker" sequence between
   preliminary and final status; it just sends the final status
   directly as a length-12 chunk. The parser now distinguishes a
   length-12 data chunk from a final-status chunk by parsing the body:
   if the leading u16 carries CMPL/ERR/END/TIMO bits, it is the final
   status, otherwise it is data.

Two new offline tests cover the no-data path (empty payload and
error-final variants). The existing with-data test is updated to use
the new default tmo_ms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NIEnet100InstrSession.read() now converts self.timeout (seconds, or
None for infinite) to the per-call tmo_ms argument of ibrd. Without
this the wire layer always used its DEFAULT_IBRD_TMO_MS regardless of
what the caller had set via inst.timeout = N, and changing the pyvisa
timeout had no effect on actual device-data reads.

None on the session translates to DEFAULT_IBRD_TMO_MS as a finite
upper bound: the wire layer does not support "no timeout" — passing 0
tells the bridge to return immediately, which is the opposite of what
infinite means.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ch by string board

The rname.ResourceName.interface_type_const property maps the interface
type to an InterfaceType enum entry via lower().replace("-", "_"). The
previous "NIENET100-TCPIP" mapped to "nienet100_tcpip", which did not
match the enum name "ni_enet100_tcpip" and fell back to
InterfaceType.unknown = -1, so highlevel.open could not resolve a
session class. With the dash before ENET100 the lookup resolves.

While here, type the boards dispatch table as Dict[str, ...] to mirror
rname.GPIBInstr.board (a str). The after_parsing path already stored
the entry under that key and the dispatch hook already looked it up
unchanged; the previous Dict[int, ...] annotation was misleading and
test_intfc_open_registers_board would have failed on real hardware.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bytewarrior and others added 5 commits June 1, 2026 16:26
The bridge rejects the IbcTMO property setter ('P 03') once a bracket
is open (commit 7da6c94 documented this for the wire-level tests). The
wire-level ibrd verb already receives the pyvisa session timeout via
its per-call tmo_ms argument (commit cecd00d), so the session does not
need to push the timeout to the bridge any other way.

Widen the socket-timeout buffer to max(timeout + 5.0, 8.0) so the
socket does not fire before the bridge surfaces its own timeout: the
bridge has a noticeable built-in minimum delay before reporting a
timeout, regardless of the per-call tmo_ms, and the previous +1.0 s
buffer was not enough for short pyvisa timeouts (e.g. 200 ms).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three fixes to the session-level fixture and discovery test:

- Use \n for write/read termination instead of pyvisa's library default
  of \r\n; many older GPIB instruments reject the \r and respond with a
  malformed payload (or nothing). A new PYVISA_TEST_GPIB_TERM env var
  overrides the default for instruments that need it.
- Query rm.list_resources("?*::INTFC") rather than the default
  ?*::INSTR, which filters out our INTFC-class bridge resource.
- Match discovered resource strings by resolved IP because the
  discovery emits IPs while the configured HOST may be a hostname.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ibsic was added in commit a08044c, so the comment that paired it with
ibsre as "not yet implemented" no longer reflects reality. Clarify
that only ibsre is missing, and spell out that the non-GTL REN modes
intentionally surface error_nonsupported_operation per the pyvisa
contract for unsupported modes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ruff format flagged the TERM definition as exceeding the project's
88-character line limit. No semantic change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the session-layer NIEnet100InstrSession owned a
_bracket_open flag that was set only after open_gpib_session returned
without raising. _cleanup_interface gated close_gpib_session on that
flag. The window between Frame F (bracket open, 58 01 01) being acked
by the bridge and the flag being set covered every later frame inside
open_gpib_session (Frame G notify-off sync) plus the immediate post-
call path — an exception thrown in that window left the bridge with a
session slot allocated against a TCP connection that was torn down
without the matching 58 00 01.

Move the flag to EnetConnection._bracket_open, flip it inside
_transact_bracket after the box acks the frame, and have close()
unconditionally invoke close_gpib_session() so any teardown — happy
path or mid-open error — releases the bracket. close_gpib_session()
becomes a no-op when no bracket is open, so the session layer can
drop its own tracking entirely.

Verified against an NI MAX capture (analysis in companion notes): NI
sends only 58 00 01 on session end — no Online=0, no mode reset, no
notify-off on main. The previous adapter matched that on the happy
path; this change extends the same behaviour to error paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@MatthieuDartiailh

Copy link
Copy Markdown
Member

Thanks for this. It will take me time to go through all of it. Do not hesitate to ping me if you do hear back within a couple of weeks.

@bytewarrior

Copy link
Copy Markdown
Author

Thanks, Matthieu. Your efforts are very much appreciated.

Comment thread pyvisa_py/nienet100.py
Comment on lines +36 to +50
try:
_IFACE_NIENET100_TCPIP = constants.InterfaceType.ni_enet100_tcpip
except AttributeError as e:
raise ImportError(
"pyvisa-py NI GPIB-ENET/100 support requires pyvisa with "
"InterfaceType.ni_enet100_tcpip; please update pyvisa."
) from e

try:
_RNAME_NIENET100_TCPIP_INTFC = rname.NIEnet100TCPIPIntfc
except AttributeError as e:
raise ImportError(
"pyvisa-py NI GPIB-ENET/100 support requires pyvisa with "
"rname.NIEnet100TCPIPIntfc; please update pyvisa."
) from e

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no reason to have 2 separate blocks here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed the second one and reworked the text message.

Comment thread pyvisa_py/nienet100.py
for the session lifetime. The connection acts as a connectivity sentinel
(the box rejects Device-I/O on stale sessions, so an open socket is a
reliable health signal). INSTR sessions do **not** share this connection;
they each open their own.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line at end of docstring

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to reformat all of the docstrings then, in order to keep it consistent across the codebase.
I did it this way, as large portions of the code do not have blank lines either.

So the convention is to have a blank line at the end of each docstring?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I follow numpy suggested format which does mandate a blank line at the end of a docstring.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've added a blank line to all multi-line docstrings.

Comment thread pyvisa_py/nienet100.py Outdated
Comment on lines +324 to +329
# The wire-level ibrd always reads the full message (until EOI/EOS);
# truncate here if the caller's max-count is smaller. Extra bytes
# are dropped — ibrd has no resume semantics, so a caller that
# supplied too small a count loses the tail.
if len(data) > count:
return bytes(data[:count]), StatusCode.success_max_count_read

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not acceptable. The session should maintain an internal buffer to allow reading responses one byte at a time (useful in debugging).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I have added an internal read buffer to the INSTR session. Reading responses byte-wise is now possible.

Comment thread pyvisa_py/nienet100.py
return bytes(data), StatusCode.success_termination_character_read
return bytes(data), StatusCode.success

def clear(self) -> StatusCode:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internal buffer should also be cleared.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, as this followed directly from adding the read buffer.

Comment thread pyvisa_py/nienet100.py Outdated
return StatusCode.error_system_error


# --- (gpib, INSTR) dispatch hook --------------------------------------------

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following feels quite brittle since it relies heavily on the order of the imports. It may be better to have a central location registering GPIB::INSTR if any of the possible interfaces is functional and managing the dispatching in a single place.

@bytewarrior bytewarrior Jun 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is exactly the point where I was unsure how to solve it.

This single place you're referring to is probably supposed to be in pyvisa, not pyvisa-py?

Thinking further, and as an alternative, one could perhaps add a dispatcher here in pyvisa-py, something like gpib_dispatch.py. This one would populate the (gpib, INSTR) slot once and for all without the cumbersome pop/re-register stuff.

It could also create an ordered candidate list of backends, to which each backend would contribute if imported successfully.

What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea was indeed to have that single point of registration living in pyvisa-py.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, and I suppose it's okay to implement this inside this PR and not create a separate one?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so I went ahead and gave it a try. Instead of the earlier approach of popping and re-wrapping the registry at import time, the backends are now registered as priority-ordered resolvers:

  • NI-ENET/100 bridge (priority 10): board bound to a NI GPIB/ENET-100 INTFC
  • Prologix (priority 20): board bound to a Prologix controller
  • native linux-gpib / gpib-ctypes (priority 100): catch-all fallback

register_builtin_backends() imports each backend defensively, so a backend whose import fails (e.g. gpib.py raising when no GPIB library is installed) is simply absent from the list. As a consequence, the remaining backends keep working. The module itself has no hard dependency on any GPIB library, so it can own the slot even when gpib.py fails to import.

Dispatch no longer depends on the order in which backends are imported, and adding another GPIB backend in the future is a one-line register_backend().

I've yet come across another question: list_resources() still enumerates only native GPIB listeners (it delegates to gpib._find_listeners() exactly as the old GPIBSessionDispatch.list_resources did). Prologix has never been listed (the old # FIXME is still accurate), and GPIB-ENET/100s are discovered at the INTFC level via NI-ENET100-TCPIP…::INTFC. For now, I kept that behaviour identical rather than change discovery semantics.
Is this okay?

@MatthieuDartiailh

Copy link
Copy Markdown
Member

First quick round of review for high level stuff and interfaces.

bytewarrior and others added 13 commits June 4, 2026 07:18
…ta loss between reads.

A new field _read_buffer was added to INSTR session. read() now uses the read buffer; ibrd is only called when the buffer is empty. Exactly count bytes will be retrieved and the rest will remain in the buffer for future reads.

clear() consequently clears the buffer. In addition, _cleanup_interface() clears the buffer on session close to keep everything clean and tidy.
The new NI GPIB-ENET/100 INTFC session is registered at backend load
time, so test_sessions must include it in the expected set of valid
session classes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a single dispatcher that resolves a GPIB::INSTR resource to the
right backend session via an ordered list of backend resolvers, instead
of each backend module popping and re-wrapping the registry slot at import
time. Backends register through register_backend(); a backend that fails
to import is simply absent, so dispatch no longer depends on import order.

This commit only adds the scaffold and its unit tests — the dispatcher is
not registered yet, so behaviour is unchanged until the existing backends
are migrated onto register_backend().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add register_builtin_backends() to the central dispatcher, registering
the Prologix and native linux-gpib/gpib-ctypes backends as priority-ordered
resolvers. Backends are imported defensively; the native driver, whose
module raises on import when no GPIB library is present, falls back to an
unavailable session that preserves the actionable install hint at open
time. Native is the catch-all and is wired last.

Still inert: register_builtin_backends() is not called yet and the central
dispatcher is not registered, so behaviour is unchanged. Bridge wiring and
the activation switch follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register the bridge resolver in register_builtin_backends() ahead of
Prologix and native, so a board bound to a bridge INTFC always resolves to
the bridge session. Imported defensively like the other backends.

Still inert: register_builtin_backends() is not yet called and the central
dispatcher is not registered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register GPIBInstrDispatch as the single owner of the (gpib, INSTR) slot
and wire the in-tree backends through register_builtin_backends(), called
from highlevel after the backend imports. Remove the import-order-dependent
mechanisms this replaces:

- gpib.py no longer registers GPIBSessionDispatch; its list_resources moves
  to the central dispatcher and its native dispatch becomes a resolver.
- nienet100.py no longer pops and re-wraps the registry slot.

highlevel imports gpib_dispatch unconditionally, so the slot is owned even
when gpib.py fails to import, and dispatch no longer depends on the order
in which backends are imported. list_resources behaviour is unchanged
(native listeners only). Docstrings updated to point at the new owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dispatcher is registered only for the (gpib, INSTR) slot, so a parsed
resource reaching it is always a GPIBInstr. Type the resolver protocol and
_board_resolver accordingly and cast the parsed name once in __new__, so
accessing parsed.board no longer trips mypy on the base ResourceName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GPIB verbs (ibwrt, ibrd, ibclr, ...) are implemented as module-level
functions and attached to EnetConnection at the end of the module. mypy
could not see them, flagging every conn.ibXXX call site. Declare the verb
signatures in a TYPE_CHECKING block in the class body and silence the
method-reassignment guard on the binding statements. No runtime change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard the board deregistration in close() so a None board key is not
passed to dict.pop, and annotate the bridge host lookup where pyvisa does
not yet export the NIEnet100TCPIPIntfc rname type. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Narrow the env-configured Optional HOST/PAD with asserts (guaranteed
non-None by the require_bridge/require_instrument skips) and cast the
opened resource to MessageBasedResource before setting termination
attributes. Also refresh a stale docstring that pointed at the old
per-module dispatcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@MatthieuDartiailh MatthieuDartiailh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A general comment, I did not go through everything yet. Since the supported Python version is 3.10 for new/edited code I would like to avoid the typing imports List, Tuple, etc

bytewarrior and others added 2 commits June 6, 2026 09:14
Replace deprecated typing aliases (List, Tuple, Dict, Type, Optional,
Union) with builtin generics and PEP 604 unions across the NI
GPIB-ENET/100 modules and the GPIB dispatch/native backend. Import
Callable/Iterator from collections.abc. Addresses review feedback that
new/edited code should avoid the typing imports since 3.10 is the
supported floor.

gpib.make_unavailable keeps type[Any] (not bare type) so mypy's super()
MRO check on the re-defined GPIBSession name still resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five tests built the main socket with socket.socketpair() and read its
getsockname() to verify the (ip, port) the 'O'/'U' verbs embed in the
wire frame. On Unix socketpair() yields AF_UNIX sockets whose
getsockname() is the empty string, so the unpack raised ValueError
(test_close_swallows_notify_off_errors additionally failed because that
ValueError is not an OSError and escaped the cleanup handler).

Add a _bound_inet_socket() helper that binds an AF_INET socket to an
ephemeral loopback port and use it for the main socket in these tests.
The main socket is only queried for its address there, never read or
written, so a bind without connect is sufficient. The skip-notify-off
test keeps socketpair() since it never calls getsockname().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bytewarrior

Copy link
Copy Markdown
Author

I've made two new commits-- one addressing the Python 3.10 compatibility/ syntax issue.
One more on the way as I noticed a few offline tests would not run on Linux.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants