Files
scanengine-3/hardware/tektronix_base.py
Thomas Ales 6e8c1cb7a2 Auto-align: level the sample on the DC bias levels from the camera window
The operator frames a good spot, confirms the two DC levels the detector
reads there, and the rig then measures its own tilt: step 1.5 mm either side
on X and then on Y, and tilt the platform until those levels come back.  The
correction that fixes an offset point is the correction that levels the whole
travel — height error and tilt effect are both proportional to the offset —
so the procedure ends by applying it and leaving it applied.

Both directions are measured from the same starting tilt and averaged, which
makes their disagreement a flatness read-out rather than something averaged
away silently.

core/auto_align.py holds the geometry and the search, Qt-free.  The three
T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts
along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without
touching X (tilt_response derives that, and the tests pin it — an axis map
that drifts would still converge, on the wrong axis).  The search is a secant
null on the split-detector difference: probe once to learn what a microstep
is worth, sign included, then step at the null.  It refuses to servo on a
scope that has not re-triggered, escalates a probe that reads as no response
before calling an axis dead, and stops at a per-axis travel limit.

gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event
rather than a queued command, because the worker is inside a long handler for
the whole run.  The camera window carries the button and the progress window,
and locks the scan panel and the jog pads while a run owns the stage.

Adds immediate MEAN measurements and an acquisition count to the scope
driver, and read_bias_mv to core/scope_inspect — the one scalar the
inspection state was missing.

KNOWN_ISSUES.md records what only the rig can settle: the probe step, the
travel limit, the hold current, and whether the piston the X phase applies
alongside its tilt matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:00:36 -05:00

737 lines
27 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Base class for Tektronix oscilloscope control with raw SCPI command support.
"""
import socket
class TektronixOscilloscopeBase:
"""
Base class for Tektronix oscilloscope instruments.
Provides low-level SCPI command interface for fast data transfer.
"""
# Valid acquisition modes (short and long forms)
# SCPI uses capital letters for short form, scope responds with full uppercase
ACQUIRE_MODES = {
'SAMPLE': ['SAM', 'SAMPLE', 'SAMple'],
'PEAKDETECT': ['PEAK', 'PEAKDETECT', 'PEAKdetect'],
'HIRES': ['HIR', 'HIRES', 'HIRes'],
'AVERAGE': ['AVE', 'AVERAGE', 'AVErage'],
'ENVELOPE': ['ENV', 'ENVELOPE', 'ENVelope']
}
# Valid trigger coupling modes
TRIGGER_COUPLING = {
'DC': ['DC'],
'HFREJ': ['HFRej', 'HFREJ'],
'LFREJ': ['LFRej', 'LFREJ'],
'NOISEREJ': ['NOISErej', 'NOISEREJ']
}
# Valid trigger slope options
TRIGGER_SLOPE = {
'RISE': ['RISe', 'RISE'],
'FALL': ['FALL'],
'EITHER': ['EITher', 'EITHER']
}
# Valid trigger modes
TRIGGER_MODE = {
'AUTO': ['AUTO'],
'NORMAL': ['NORMal', 'NORMAL']
}
# Valid channel coupling modes
CHANNEL_COUPLING = {
'AC': ['AC'],
'DC': ['DC']
}
# Valid channel termination values (ohms)
CHANNEL_TERMINATION = [50, 1000000]
# Valid data encoding formats
DATA_ENCODING = {
'ASCII': ['ASCIi', 'ASCII'],
'RIBINARY': ['RIBinary', 'RIBINARY'], # Signed integer, MSB first (recommended)
'RPBINARY': ['RPBinary', 'RPBINARY'], # Positive integer, MSB first
'FPBINARY': ['FPBinary', 'FPBINARY'], # Floating point binary
'SRIBINARY': ['SRIbinary', 'SRIBINARY'], # Signed integer, byte-swapped
'SRPBINARY': ['SRPbinary', 'SRPBINARY'], # Positive integer, byte-swapped
'SFPBINARY': ['SFPbinary', 'SFPBINARY'] # Floating point binary, byte-swapped
}
# Valid waveform output preamble encoding
WFMOUTPRE_ENCODING = {
'BINARY': ['BINary', 'BINARY'],
'ASCII': ['ASCii', 'ASCII']
}
# Valid byte order options
BYTE_ORDER = {
'LSB': ['LSB'], # Least significant byte first
'MSB': ['MSB'] # Most significant byte first
}
def __init__(self, resource_name=None, port=4000, timeout=5.0, terminator='\n'):
"""
Initialize the oscilloscope base class.
Args:
resource_name: IP address or hostname of the instrument
port: TCP port for SCPI communication (default: 4000)
timeout: Socket timeout in seconds (default: 5.0)
terminator: Line terminator for SCPI commands (default: '\n')
"""
self.resource_name = resource_name
self.port = port
self.timeout = timeout
self.terminator = terminator
self.socket = None
self._connected = False
def connect(self):
"""Establish TCP socket connection to the oscilloscope."""
if not self.resource_name:
raise ValueError("resource_name (IP address/hostname) must be provided")
if self._connected:
return
try:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.settimeout(self.timeout)
self.socket.connect((self.resource_name, self.port))
self._connected = True
except socket.error as e:
self.socket = None
self._connected = False
raise ConnectionError(
f"Failed to connect to {self.resource_name}:{self.port} - {e}") from e
def disconnect(self):
"""
Close TCP socket connection to the oscilloscope.
"""
if self.socket:
try:
self.socket.close()
except Exception:
pass
finally:
self.socket = None
self._connected = False
def _normalize_channel(self, channel):
"""Normalize channel input to integer (1-4)."""
if isinstance(channel, str):
channel_upper = channel.upper()
if not (channel_upper.startswith('CH') and len(channel_upper) == 3 and channel_upper[2].isdigit()):
raise ValueError(f"Invalid channel: {channel}. Must be CH1, CH2, CH3, or CH4")
channel = int(channel_upper[2])
if not isinstance(channel, int) or channel < 1 or channel > 4:
raise ValueError(f"Invalid channel: {channel}. Must be 1-4")
return channel
def set_acquire_mode(self, mode):
"""Set the acquisition mode."""
# Normalize mode to uppercase for comparison
mode_upper = mode.upper()
# Check if mode is valid (any short or long form)
valid = False
for variants in self.ACQUIRE_MODES.values():
if mode_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.ACQUIRE_MODES.values():
valid_options.extend(variants)
raise ValueError(f"Invalid acquisition mode: {mode}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"ACQuire:MODe {mode}")
def get_fastframe_state(self):
"""Query the current FastFrame state."""
response = self.query("HORizontal:FASTframe:STATE?")
return int(response)
def set_fastframe_state(self, state):
"""Enable or disable FastFrame mode."""
# Convert boolean to int if needed
if isinstance(state, bool):
state = 1 if state else 0
# Validate state
if state not in (0, 1):
raise ValueError(f"Invalid FastFrame state: {state}. Must be 0 (off) or 1 (active)")
self.write(f"HORizontal:FASTframe:STATE {state}")
def set_fastframe_count(self, count):
"""Set the number of FastFrame frames."""
# Validate count
if not isinstance(count, int) or count <= 0:
raise ValueError(f"Invalid frame count: {count}. Must be a positive integer")
self.write(f"HORizontal:FASTframe:COUNt {count}")
def get_fastframe_max_frames(self):
"""Query how many FastFrame frames the current horizontal settings allow."""
return int(self.query("HORizontal:FASTframe:MAXFRames?"))
def get_record_length(self):
"""Query the current horizontal record length."""
response = self.query("HORizontal:MODe:RECOrdlength?")
return int(response)
def set_sample_rate(self, rate):
"""Set the horizontal sample rate."""
# Validate rate
if not isinstance(rate, (int, float)) or rate <= 0:
raise ValueError(f"Invalid sample rate: {rate}. Must be a positive number")
self.write(f"HORizontal:MODe:SAMPLERate {rate}")
def set_trigger_slope(self, slope):
"""Set the trigger slope."""
slope_upper = slope.upper()
# Check if slope is valid
valid = False
for variants in self.TRIGGER_SLOPE.values():
if slope_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.TRIGGER_SLOPE.values():
valid_options.extend(variants)
raise ValueError(f"Invalid trigger slope: {slope}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"TRIGger:A:EDGE:SLOpe {slope}")
def set_trigger_source(self, source):
"""Set the trigger source."""
# Convert integer to channel string if needed
if isinstance(source, int):
if source < 1 or source > 4:
raise ValueError(f"Invalid trigger source: {source}. Must be 1-4")
source = f"CH{source}"
# Validate channel format
source_upper = source.upper()
if not (source_upper.startswith('CH') and len(source_upper) == 3 and source_upper[2].isdigit()):
raise ValueError(f"Invalid trigger source: {source}. Must be CH1, CH2, CH3, or CH4")
channel_num = int(source_upper[2])
if channel_num < 1 or channel_num > 4:
raise ValueError(f"Invalid trigger source: {source}. Channel must be 1-4")
self.write(f"TRIGger:A:EDGE:SOUrce {source}")
def set_trigger_level(self, channel, level):
"""Set the trigger level for a specific channel."""
# Convert to channel number if string
if isinstance(channel, str):
channel_upper = channel.upper()
if not (channel_upper.startswith('CH') and len(channel_upper) == 3 and channel_upper[2].isdigit()):
raise ValueError(f"Invalid channel: {channel}. Must be CH1, CH2, CH3, or CH4")
channel = int(channel_upper[2])
# Validate channel number
if not isinstance(channel, int) or channel < 1 or channel > 4:
raise ValueError(f"Invalid channel: {channel}. Must be 1-4")
# Validate level
if not isinstance(level, (int, float)):
raise ValueError(f"Invalid trigger level: {level}. Must be a number")
self.write(f"TRIGger:A:LEVel:CH{channel} {level}")
def set_trigger_mode(self, mode):
"""Set the trigger mode."""
mode_upper = mode.upper()
# Check if mode is valid
valid = False
for variants in self.TRIGGER_MODE.values():
if mode_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.TRIGGER_MODE.values():
valid_options.extend(variants)
raise ValueError(f"Invalid trigger mode: {mode}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"TRIGger:A:MODe {mode}")
# ========== Channel Control Methods ==========
def set_channel_bandwidth(self, channel, bandwidth):
"""Set the bandwidth for a specific channel."""
channel = self._normalize_channel(channel)
self.write(f"CH{channel}:BANdwidth {bandwidth}")
def set_channel_coupling(self, channel, coupling):
"""Set the coupling mode for a specific channel."""
channel = self._normalize_channel(channel)
coupling_upper = coupling.upper()
# Validate coupling
valid = False
for variants in self.CHANNEL_COUPLING.items():
if coupling_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.CHANNEL_COUPLING.values():
valid_options.extend(variants)
raise ValueError(f"Invalid coupling mode: {coupling}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"CH{channel}:COUPling {coupling}")
def set_channel_label_name(self, channel, name):
"""Set the label name for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(name, str):
raise ValueError(f"Invalid label name: {name}. Must be a string")
# Need to quote the string for SCPI
self.write(f'CH{channel}:LABel:NAMe "{name}"')
def set_channel_position(self, channel, position):
"""Set the vertical position for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(position, (int, float)):
raise ValueError(f"Invalid position: {position}. Must be a number")
self.write(f"CH{channel}:POSition {position}")
def set_channel_scale(self, channel, scale):
"""Set the vertical scale for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(scale, (int, float)) or scale <= 0:
raise ValueError(f"Invalid scale: {scale}. Must be a positive number")
self.write(f"CH{channel}:SCAle {scale}")
def set_channel_termination(self, channel, termination):
"""Set the termination for a specific channel."""
channel = self._normalize_channel(channel)
if termination not in self.CHANNEL_TERMINATION:
raise ValueError(f"Invalid termination: {termination}. "
f"Valid options: {', '.join(map(str, self.CHANNEL_TERMINATION))}")
self.write(f"CH{channel}:TERmination {termination}")
# ========== Measurement Methods ==========
# Immediate measurements the alignment/inspection code asks for. The
# instrument accepts many more; this list is what has been exercised here,
# and an unlisted type is far more likely to be a typo than a deliberate
# choice.
MEASUREMENT_TYPES = {
'MEAN': ['MEAN'],
'AMPLITUDE': ['AMPlitude', 'AMPLITUDE'],
'MAXIMUM': ['MAXimum', 'MAXIMUM'],
'MINIMUM': ['MINImum', 'MINIMUM'],
'PK2PK': ['PK2pk', 'PK2PK'],
'RMS': ['RMS'],
}
# Tektronix returns this sentinel when a measurement cannot be made (no
# acquisition yet, source off, signal outside the graticule). It is a
# valid float, so it has to be caught explicitly or it reads as a
# 1e38 V measurement.
MEASUREMENT_INVALID = 9.9e37
def measure_immediate(self, channel, measurement_type='MEAN'):
"""Take an immediate measurement on one channel and return it in volts.
"Immediate" measurements are computed on demand and are not added to
the scope's on-screen measurement badges, so this leaves whatever the
operator has set up on the front panel untouched.
Raises ValueError if the instrument reports the measurement as
unavailable, which on a triggered-acquisition scope usually means it
has not acquired anything yet.
"""
channel = self._normalize_channel(channel)
if measurement_type.upper() not in self.MEASUREMENT_TYPES:
raise ValueError(
f"Invalid measurement type: {measurement_type}. "
f"Valid options: {', '.join(self.MEASUREMENT_TYPES)}")
self.write(f"MEASUrement:IMMed:SOUrce1 CH{channel}")
self.write(f"MEASUrement:IMMed:TYPe {measurement_type}")
response = self.query("MEASUrement:IMMed:VALue?")
try:
value = float(response)
except ValueError as exc:
raise ValueError(
f"Unparseable {measurement_type} measurement on CH{channel}: "
f"{response!r}") from exc
if abs(value) >= self.MEASUREMENT_INVALID:
raise ValueError(
f"CH{channel} {measurement_type} is unavailable (the scope "
f"returned its no-measurement sentinel). Check that the "
f"channel is on and that the acquisition is triggering.")
return value
def get_acquisition_count(self):
"""Number of acquisitions since the acquisition was last started.
A caller polling a free-running scope uses this to tell a fresh
reading from a stale one: if the count has not moved, the record has
not changed and every measurement taken off it is the previous
answer.
"""
return int(float(self.query("ACQuire:NUMACq?")))
# ========== Waveform Transfer Methods ==========
def set_data_source(self, source):
"""Set the data source for waveform transfer."""
# Convert integer to channel string if needed
if isinstance(source, int):
if source < 1 or source > 4:
raise ValueError(f"Invalid data source: {source}. Must be 1-4")
source = f"CH{source}"
# Validate channel format
source_upper = source.upper()
if not (source_upper.startswith('CH') and len(source_upper) == 3 and source_upper[2].isdigit()):
raise ValueError(f"Invalid data source: {source}. Must be CH1, CH2, CH3, or CH4")
channel_num = int(source_upper[2])
if channel_num < 1 or channel_num > 4:
raise ValueError(f"Invalid data source: {source}. Channel must be 1-4")
self.write(f"DATa:SOUrce {source}")
def set_data_encoding(self, encoding):
"""Set the curve transfer encoding (e.g. RIBinary = signed int, MSB first)."""
valid = ('ASCII', 'RIBinary', 'RPBinary', 'FPBinary',
'SRIbinary', 'SRPbinary', 'SFPbinary')
if encoding.upper() not in [v.upper() for v in valid]:
raise ValueError(f"Invalid data encoding: {encoding}. "
f"Valid options: {', '.join(valid)}")
self.write(f"DATa:ENCdg {encoding}")
def set_data_width(self, width):
"""Set bytes per sample for curve transfers."""
if width not in (1, 2):
raise ValueError(f"Invalid data width: {width}. Must be 1 or 2")
self.write(f"DATa:WIDth {width}")
def query_wfmoutpre(self):
"""Query all waveform output preamble parameters."""
return self.query("WFMOutpre?")
def transfer_curve(self):
"""
Transfer waveform curve data from the oscilloscope.
This method reads binary curve data according to the current DATa:SOUrce,
DATa:ENCdg, and WFMOutpre settings.
Returns:
bytes: Raw binary curve data
Raises:
RuntimeError: If not connected or transfer fails
"""
# Send the CURVe? query
self.write("CURVe?")
# Read the binary data using IEEE 488.2 format
return self.read_raw()
def transfer_fastframe(self, parse=True, byte_count=1, signed=True, byte_order='MSB'):
"""
Transfer all FastFrame waveform data from the oscilloscope.
When FastFrame is enabled, a single CURVe? query returns all frames
as sequential IEEE 488.2 binary blocks. This method reads all frames
efficiently in a single operation.
Args:
parse: If True, parse the raw bytes into integer arrays (default: True)
byte_count: Number of bytes per sample for parsing (1 or 2)
signed: True for signed integer parsing, False for unsigned
byte_order: 'MSB' or 'LSB' for byte order when parsing
Returns:
list: List of waveforms. If parse=True, each waveform is a list of
integer ADC values. If parse=False, each waveform is raw bytes.
Raises:
RuntimeError: If not connected, FastFrame not enabled, or transfer fails
ValueError: If parsing parameters are invalid
Example:
# Enable FastFrame with 1000 frames
scope.set_fastframe_state(True)
scope.set_fastframe_count(1000)
# Acquire data
scope.write("ACQuire:STATE RUN")
time.sleep(2)
scope.write("ACQuire:STATE STOP")
# Transfer all frames at once
waveforms = scope.transfer_fastframe()
print(f"Got {len(waveforms)} frames, {len(waveforms[0])} points each")
"""
# Verify FastFrame is enabled
if not self.get_fastframe_state():
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
# Use the number of frames actually acquired, not the configured maximum.
# If the stage stops early, fewer triggers arrive and the scope captures
# fewer frames than configured — reading the configured count would block.
acquired = int(self.query("ACQuire:NUMFRAMESACQuired?"))
if acquired <= 0:
raise RuntimeError("Scope acquired 0 FastFrame frames — no data to read")
frame_count = acquired
# Send a single CURVe? query - scope will return all frames
self.write("CURVe?")
# Read all frames as sequential IEEE 488.2 binary blocks
waveforms = []
for _ in range(frame_count):
curve_bytes = self.read_raw()
if parse:
waveform = self.parse_curve_data(
curve_bytes,
byte_count=byte_count,
signed=signed,
byte_order=byte_order
)
else:
waveform = curve_bytes
waveforms.append(waveform)
return waveforms
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
bytes_per_sample=1):
"""Transfer a whole FastFrame burst as one contiguous buffer.
Unlike transfer_fastframe this does not care how the scope frames the
response — it accumulates blocks until it has the expected byte count,
so one large IEEE block and one block per frame both work. Returns a
bytearray of frame_count * samples_per_frame * bytes_per_sample bytes.
"""
if not self.get_fastframe_state():
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
expected = frame_count * samples_per_frame * bytes_per_sample
if expected <= 0:
raise RuntimeError(
f"Nothing to transfer: {frame_count} frames × "
f"{samples_per_frame} samples × {bytes_per_sample} bytes"
)
self.write("CURVe?")
buf = bytearray()
while len(buf) < expected:
block = self.read_raw(expected_bytes=expected - len(buf))
if not block:
raise RuntimeError(
f"Scope returned an empty block {len(buf)}/{expected} bytes "
"into the burst transfer"
)
buf += block
if len(buf) != expected:
raise RuntimeError(
f"Burst transfer overran: got {len(buf)} bytes, expected {expected}"
)
return buf
def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'):
"""Parse raw curve data into integer array."""
if byte_count not in (1, 2):
raise ValueError(f"Invalid byte count: {byte_count}. Must be 1 or 2")
if byte_order not in ('MSB', 'LSB'):
raise ValueError(f"Invalid byte order: {byte_order}. Must be 'MSB' or 'LSB'")
values = []
if byte_count == 1:
# 8-bit data
for byte in curve_bytes:
if signed:
# Convert unsigned byte to signed
value = byte if byte < 128 else byte - 256
else:
value = byte
values.append(value)
else:
# 16-bit data
num_samples = len(curve_bytes) // 2
for i in range(num_samples):
byte1 = curve_bytes[i * 2]
byte2 = curve_bytes[i * 2 + 1]
if byte_order == 'MSB':
# Most significant byte first
value = (byte1 << 8) | byte2
else:
# Least significant byte first
value = (byte2 << 8) | byte1
if signed:
# Convert unsigned to signed
if value >= 32768:
value = value - 65536
values.append(value)
return values
def write(self, command):
"""Send a raw SCPI command to the instrument."""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
if not command.endswith(self.terminator):
command += self.terminator
self.socket.sendall(command.encode('ascii'))
def query(self, command):
"""Send a SCPI query and return the response."""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
self.write(command)
response = b''
terminator_bytes = self.terminator.encode('ascii')
while True:
chunk = self.socket.recv(4096)
if not chunk:
break
response += chunk
if terminator_bytes in chunk:
break
return response.decode('ascii').strip()
def _recv_exact(self, count):
"""Read exactly `count` bytes; recv() is free to return fewer."""
chunks = []
remaining = count
while remaining > 0:
chunk = self.socket.recv(min(remaining, 65536))
if not chunk:
raise RuntimeError("Connection closed while reading data")
chunks.append(chunk)
remaining -= len(chunk)
return b''.join(chunks)
def read_raw(self, expected_bytes=None):
"""
Read raw binary data from the instrument.
Useful for fast waveform data transfer. Handles IEEE 488.2 binary block format.
Format: #<N><digits><data><newline>
where N is a digit indicating how many digits follow,
and those digits specify the length of the data block.
`#0` announces an indeterminate-length block, normally delimited by EOI
— which a raw socket never sees. Pass expected_bytes to say how many
bytes to take in that case.
Returns:
bytes: Raw binary data (without IEEE 488.2 header)
Raises:
RuntimeError: If not connected or invalid format
socket.error: If communication fails
"""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
# Read the '#' character
header = self.socket.recv(1)
if header != b'#':
# Read whatever else is buffered so the error shows the actual response
self.socket.setblocking(False)
try:
more = self.socket.recv(100)
except OSError:
more = b''
self.socket.setblocking(True)
raise RuntimeError(f"Invalid IEEE 488.2 header: expected '#', got {header}, followed by: {more[:50]}")
# Read the digit indicating length of length field
length_of_length = self.socket.recv(1)
if not length_of_length.isdigit():
raise RuntimeError(f"Invalid length specifier: {length_of_length}")
num_digits = int(length_of_length)
if num_digits == 0:
# Indeterminate length: no byte count follows, and the EOI that
# would delimit it does not exist on a raw socket.
if expected_bytes is None:
raise RuntimeError(
"Scope returned an indeterminate-length block (#0); "
"read_raw needs expected_bytes to size it over a socket"
)
data_length = expected_bytes
else:
data_length = int(self._recv_exact(num_digits))
data = self._recv_exact(data_length)
# Read the trailing newline / block separator
self._recv_exact(1)
return data
@property
def is_connected(self):
"""Check if instrument is connected."""
return self._connected
def __enter__(self):
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.disconnect()