Skip to content

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

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

Add support for NI GPIB-ENET/100 bridge.#587
bytewarrior wants to merge 36 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>
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.

1 participant