Add support for NI GPIB-ENET/100 bridge.#587
Open
bytewarrior wants to merge 36 commits into
Open
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
with no additional dependencies (stdlib
socket/struct/threadingonly).Depends on
pyvisa#980 – Add InterfaceType.ni_enet100_tcpip, NIEnet100TCPIPIntfc rname class and Resource subclass.
Until that PR is merged,
from pyvisa_py import nienet100raisesImportError, whichhighlevel.pyswallows at debug level (mirrors thevicppattern). All NIENET100 tests skip cleanly in that case, so this PR remains importable today; it just stays inert.What's added
pyvisa_py/protocols/nienet100.py)pyvisa_py/protocols/nienet100_discovery.py) — UDP broadcast on the local broadcast domain.pyvisa_py/nienet100.py) —NIEnet100TCPIPIntfcSessionforNI-ENET100-TCPIP::INTFC,NIEnet100InstrSessionforGPIB::INSTRresources that route through a registered bridge.gpib.py'sGPIBSessionDispatchand falls back to it for non-NIENET100 boards. Coexists with Prologix: load order isprologix → gpib → nienet100, dispatch order atopen_resourceisNIENET100 → Prologix → linux-gpib. Users wire each bridge to a distinct board number.pyvisa_py/highlevel.py(one newtry/exceptblock).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:Without
PYVISA_TEST_NIENET100_HOSTset, 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:
gpib_control_renmodes returnerror_nonsupported_operation— onlyVI_GPIB_REN_DEASSERT_GTL(which is by far the common case) is wired. The other six need anibsreverb whose wire frame is not yet reverse-engineered. Prologix exposes none of these modes, so this is already a strict improvement.ibwaitis implemented at the wire layer but the bridge to pyvisa'senable_event/wait_on_eventmachinery is not.ibrspwithcnt != 1raisesNIEnet100ProtocolError. 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.ibrddata fragmentation — the parser handles it (accumulates intopayloadacross data chunks until END or final-status), but has not been validated against very large device responses.*IDN?instrument tests assume\ntermination because pyvisa's\r\ndefault chokes several older GPIB instruments. ThePYVISA_TEST_GPIB_TERMenv var overrides.The bridge itself has two intrinsic quirks worth noting for users:
tmo_msinibrddoes not lower this floor — short pyvisa timeouts (e.g.inst.timeout = 200) surface as the configurederror_timeoutbut after ~3 s wall-clock, not 200 ms. The socket-level timeout ceiling is sized to accommodate.Reviewer guidance
The commit history is structured for sequential review — each commit is self-contained and the rough phases are:
pyvisa_py/nienet100.py, dispatch hook, module loader).list_resourceswiring).ibrspcombined chunk,ibrdno-END path, IbcTMO rejection, status-header chunk wrapping).The protocol was reverse-engineered from wire captures and the
gpibhlpr.dlldecompilate; key wire-layer details (status-header chunking,ibrspcombined-chunk format,ibrdtwo-chunk model,tmo_mssemantics) are documented inline at their use sites.still open, will continue after initial feedback: