Builds a modern, testable API for the Prologix GPIB-ETHERNET controller (GPIB-USB structurally supported but untested, no hardware available): Transport abstraction (Ethernet now, serial later), GpibController for the full "++" command set, a clean-room netfinder discovery client that fixes a real chr()/struct-format bug present in vendortools/nfutil.py, and a CLI (discover/config/info/terminal). vendortools/, pty-gpib-emulator/, sampledata.txt and the manual PDF are kept untouched as reference material. Depends on the sibling bits package (editable, not yet pinned -- see its own commit) for MAC formatting and IEEE-488.2 status-byte bit access. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
4.2 KiB
Python
135 lines
4.2 KiB
Python
import socket
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
|
|
class MockTcpServer:
|
|
"""A single-connection TCP server on 127.0.0.1, driven by `handler(conn)`
|
|
in a background thread. Used to test transports/controller without
|
|
hardware."""
|
|
|
|
def __init__(self, handler):
|
|
self._handler = handler
|
|
self._listen_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self._listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._listen_sock.bind(("127.0.0.1", 0))
|
|
self._listen_sock.listen(1)
|
|
self.host, self.port = self._listen_sock.getsockname()
|
|
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _serve(self) -> None:
|
|
try:
|
|
conn, _ = self._listen_sock.accept()
|
|
except OSError:
|
|
return
|
|
try:
|
|
self._handler(conn)
|
|
finally:
|
|
conn.close()
|
|
|
|
def close(self) -> None:
|
|
self._listen_sock.close()
|
|
self._thread.join(timeout=2.0)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_server():
|
|
servers: list[MockTcpServer] = []
|
|
|
|
def make(handler) -> MockTcpServer:
|
|
server = MockTcpServer(handler)
|
|
servers.append(server)
|
|
return server
|
|
|
|
yield make
|
|
for server in servers:
|
|
server.close()
|
|
|
|
|
|
class FakeGpibServer:
|
|
"""Emulates enough of the Prologix "++" command protocol to unit-test
|
|
`GpibController` without hardware.
|
|
|
|
Tracks config command state in `self.state` and answers query/set
|
|
semantics per the Prologix manual. Also records every raw byte
|
|
received in `self.raw_received`, independent of line parsing, so tests
|
|
can check exact wire bytes (e.g. escaping) without the naive
|
|
line-splitter tripping over an escaped LF/CR inside a payload.
|
|
"""
|
|
|
|
def __init__(self, *, version: str = "Fake Prologix GPIB-ETHERNET Version 1.0"):
|
|
self.state = {
|
|
"mode": "1",
|
|
"addr": "0",
|
|
"auto": "0",
|
|
"eoi": "1",
|
|
"eos": "0",
|
|
"eot_enable": "0",
|
|
"eot_char": "0",
|
|
"read_tmo_ms": "500",
|
|
"savecfg": "1",
|
|
"status": "0",
|
|
"lon": "0",
|
|
}
|
|
self.version = version
|
|
self.spoll_response = 0
|
|
self.read_response = b""
|
|
self.raw_received = bytearray()
|
|
|
|
def handle(self, conn: socket.socket) -> None:
|
|
buf = bytearray()
|
|
conn.settimeout(5.0)
|
|
while True:
|
|
try:
|
|
chunk = conn.recv(4096)
|
|
except (socket.timeout, OSError):
|
|
break
|
|
if not chunk:
|
|
break
|
|
self.raw_received.extend(chunk)
|
|
buf.extend(chunk)
|
|
while b"\n" in buf:
|
|
idx = buf.index(b"\n")
|
|
line = bytes(buf[:idx])
|
|
del buf[: idx + 1]
|
|
self._handle_line(conn, line)
|
|
|
|
def _handle_line(self, conn: socket.socket, line: bytes) -> None:
|
|
if not line.startswith(b"++"):
|
|
return # raw instrument data; use raw_received to inspect it
|
|
self._handle_command(conn, line[2:].decode("ascii"))
|
|
|
|
def _handle_command(self, conn: socket.socket, command: str) -> None:
|
|
parts = command.split()
|
|
if not parts:
|
|
return
|
|
name, args = parts[0], parts[1:]
|
|
|
|
if name == "ver":
|
|
conn.sendall((self.version + "\n").encode("ascii"))
|
|
elif name == "read":
|
|
conn.sendall(self.read_response)
|
|
elif name == "spoll":
|
|
conn.sendall((str(self.spoll_response) + "\n").encode("ascii"))
|
|
elif name == "addr":
|
|
if args:
|
|
self.state["addr"] = " ".join(args)
|
|
else:
|
|
conn.sendall((self.state["addr"] + "\n").encode("ascii"))
|
|
elif name in self.state:
|
|
if args:
|
|
self.state[name] = args[0]
|
|
else:
|
|
conn.sendall((str(self.state[name]) + "\n").encode("ascii"))
|
|
# else: silently ignore action commands with no return value we
|
|
# don't need for these tests (clr, ifc, llo, loc, rst, srq, trg, help)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_gpib_server(mock_server):
|
|
server_obj = FakeGpibServer()
|
|
tcp_server = mock_server(server_obj.handle)
|
|
return server_obj, tcp_server
|