Skip to content

Socket Connection

TCP/IP socket communication

TCP socket implementation for SCPI communication.

SocketConnection

SocketConnection(host: str, port: int = 5025, timeout: float = 5.0)

Bases: BaseConnection

TCP socket connection for SCPI commands over Ethernet.

Initialize socket connection.

Parameters:

Name Type Description Default
host str

IP address or hostname of the oscilloscope

required
port int

TCP port number (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)

5025
timeout float

Command timeout in seconds (default: 5.0)

5.0
Source code in scpi_control/connection/socket.py
def __init__(self, host: str, port: int = 5025, timeout: float = 5.0):
    """Initialize socket connection.

    Args:
        host: IP address or hostname of the oscilloscope
        port: TCP port number (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)
        timeout: Command timeout in seconds (default: 5.0)
    """
    super().__init__(host, port, timeout)
    self._socket: Optional[socket.socket] = None
    self._buffer_size = 4096
    self._last_command: Optional[str] = None
    self._desynced = False

connect

connect() -> None

Establish TCP connection to the oscilloscope.

Raises:

Type Description
SiglentConnectionError

If connection fails

SiglentTimeoutError

If connection times out

Source code in scpi_control/connection/socket.py
def connect(self) -> None:
    """Establish TCP connection to the oscilloscope.

    Raises:
        SiglentConnectionError: If connection fails
        SiglentTimeoutError: If connection times out
    """
    if self._connected:
        return

    try:
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.settimeout(self.timeout)
        self._socket.connect((self.host, self.port))
        self._connected = True
        # A fresh socket has nothing stranded on it from a prior session.
        self._desynced = False
    except socket.timeout:
        raise exceptions.SiglentTimeoutError(f"Connection timeout: {self.host}:{self.port}")
    except socket.error as e:
        raise exceptions.SiglentConnectionError(f"Failed to connect to {self.host}:{self.port}: {e}")

disconnect

disconnect() -> None

Close the TCP connection.

Source code in scpi_control/connection/socket.py
def disconnect(self) -> None:
    """Close the TCP connection."""
    if self._socket:
        try:
            self._socket.close()
        except Exception:
            pass
        finally:
            self._socket = None
            self._connected = False

write

write(command: str) -> None

Send a SCPI command to the oscilloscope.

Parameters:

Name Type Description Default
command str

SCPI command string

required

Raises:

Type Description
SiglentConnectionError

If not connected

SiglentTimeoutError

If command times out

CommandError

If command contains non-ASCII characters or fails

Source code in scpi_control/connection/socket.py
def write(self, command: str) -> None:
    """Send a SCPI command to the oscilloscope.

    Args:
        command: SCPI command string

    Raises:
        SiglentConnectionError: If not connected
        SiglentTimeoutError: If command times out
        CommandError: If command contains non-ASCII characters or fails
    """
    if not self._connected or not self._socket:
        raise exceptions.SiglentConnectionError(f"Not connected to oscilloscope at {self.host}:{self.port}")

    with self.lock:
        try:
            if self._desynced:
                stranded = self._last_command
                discarded = self.resync()
                if discarded:
                    logger.warning("Discarded %d stale byte(s) left over from '%s' before sending on %s:%s", discarded, stranded, self.host, self.port)

            # Ensure command ends with newline
            if not command.endswith("\n"):
                command += "\n"

            # Track the most recent command for better error reporting
            self._last_command = command.strip()

            # Validate ASCII encoding before sending
            try:
                encoded_cmd = command.encode("ascii")
            except UnicodeEncodeError as e:
                raise exceptions.CommandError(f"SCPI command contains non-ASCII characters: {command!r}", command=command.strip()) from e

            self._socket.sendall(encoded_cmd)
        except socket.timeout:
            raise exceptions.SiglentTimeoutError(f"Command timeout for '{self._last_command}' on {self.host}:{self.port}")
        except socket.error as e:
            self._connected = False
            raise exceptions.SiglentConnectionError(f"Write error to {self.host}:{self.port} for command '{self._last_command}': {e}")

read

read() -> str

Read response from the oscilloscope.

Returns:

Type Description
str

Response string from oscilloscope

Raises:

Type Description
SiglentConnectionError

If not connected

SiglentTimeoutError

If read times out

Source code in scpi_control/connection/socket.py
def read(self) -> str:
    """Read response from the oscilloscope.

    Returns:
        Response string from oscilloscope

    Raises:
        SiglentConnectionError: If not connected
        SiglentTimeoutError: If read times out
    """
    if not self._connected or not self._socket:
        raise exceptions.SiglentConnectionError(f"Not connected to oscilloscope at {self.host}:{self.port}")

    with self.lock:
        try:
            data = b""
            # Counted so the timeout message below can tell "the instrument
            # sent nothing" apart from "the instrument sent leftovers we
            # threw away". Reporting only len(data) said "received 0 bytes"
            # for the second case, which points the reader at a silent
            # instrument when the real fault is a desynced stream.
            strays = 0
            start_time = time.time()

            while True:
                # Check for timeout in the read loop
                if time.time() - start_time > self.timeout:
                    self._desynced = True
                    command_context = f"for '{self._last_command}' " if self._last_command else ""
                    stray_context = f", after discarding {strays} leading stray byte(s)" if strays else ""
                    raise exceptions.SiglentTimeoutError(
                        f"Read timeout {command_context}after {self.timeout}s waiting for newline terminator " f"(received {len(data)} bytes so far{stray_context}) from {self.host}:{self.port}"
                    )

                chunk = self._socket.recv(self._buffer_size)
                if not chunk:
                    # Peer closed before the newline terminator: what we
                    # have is a fragment, and returning it as an answer is
                    # how "3.301" became "3.3".
                    self._connected = False
                    raise exceptions.SiglentConnectionError(f"Connection closed by {self.host}:{self.port} after {len(data)} byte(s) while waiting for the response to '{self._last_command}'")
                data += chunk
                # Leading NUL and terminator bytes are both leftovers,
                # never the start of this response: NUL is a normal
                # prefix some instruments prepend to EVERY response, and
                # a stray terminator is what's left behind by an earlier
                # exchange. They must be stripped TOGETHER in one pass --
                # a terminator can arrive ahead of a NUL-prefixed
                # response in the SAME chunk, and stripping only one kind
                # per iteration would break on endswith(b"\n") before the
                # NUL underneath it was ever reached. Without stripping
                # the terminator, a single stray "\n" satisfies the
                # endswith() check below and returns an EMPTY response --
                # which is how one late newline shifted every later
                # answer by one query (High-7). Only warn when the run
                # actually contained a terminator byte: a bare NUL prefix
                # is normal and not worth logging on every query.
                stripped = data.lstrip(b"\r\n\x00")
                if stripped != data:
                    leftovers = data[: len(data) - len(stripped)]
                    strays += len(leftovers)
                    if b"\n" in leftovers or b"\r" in leftovers:
                        logger.warning("Discarded %d stray terminator byte(s) left before the response to '%s' on %s:%s", len(leftovers), self._last_command, self.host, self.port)
                    data = stripped
                if data.endswith(b"\n"):
                    break

            # Decode and strip whitespace and null bytes
            try:
                response = data.decode("ascii").strip()
            except UnicodeDecodeError as e:
                # Binary on a text read means the stream position is wrong,
                # so this is a connection fault, not a command fault -- and
                # it must NOT be a ValueError subclass, which is what let
                # callers swallow it.
                self._desynced = True
                raise exceptions.SiglentConnectionError(f"Non-ASCII byte in the response to '{self._last_command}' from {self.host}:{self.port}: {data[:32]!r}") from e
            # Remove null bytes that some oscilloscopes prepend to responses
            response = response.lstrip("\x00")
            return response
        except socket.timeout:
            self._desynced = True
            command_context = f"for '{self._last_command}' " if self._last_command else ""
            raise exceptions.SiglentTimeoutError(f"Read timeout {command_context}from {self.host}:{self.port}")
        except socket.error as e:
            self._connected = False
            command_context = f" while waiting for '{self._last_command}'" if self._last_command else ""
            raise exceptions.SiglentConnectionError(f"Read error from {self.host}:{self.port}{command_context}: {e}")

query

query(command: str) -> str

Send a command and read the response.

Parameters:

Name Type Description Default
command str

SCPI query command

required

Returns:

Type Description
str

Response string from oscilloscope

Raises:

Type Description
SiglentConnectionError

If not connected

SiglentTimeoutError

If command times out

CommandError

If command fails

Source code in scpi_control/connection/socket.py
def query(self, command: str) -> str:
    """Send a command and read the response.

    Args:
        command: SCPI query command

    Returns:
        Response string from oscilloscope

    Raises:
        SiglentConnectionError: If not connected
        SiglentTimeoutError: If command times out
        CommandError: If command fails
    """
    with self.lock:
        self.write(command)
        # Small delay to allow oscilloscope to process
        time.sleep(0.01)
        return self.read()

read_raw

read_raw(size: Optional[int] = None, framing: Framing = Framing.AUTO) -> bytes

Read raw binary data from oscilloscope.

Used for reading waveform data in binary format.

Parameters:

Name Type Description Default
size Optional[int]

Number of bytes to read (None for all available)

None
framing Framing

How to interpret the response when size is None (see connection.framing.Framing). Ignored when size is given.

AUTO

Returns:

Type Description
bytes

Raw binary data

Raises:

Type Description
SiglentConnectionError

If not connected

SiglentTimeoutError

If read times out

Source code in scpi_control/connection/socket.py
def read_raw(self, size: Optional[int] = None, framing: Framing = Framing.AUTO) -> bytes:
    """Read raw binary data from oscilloscope.

    Used for reading waveform data in binary format.

    Args:
        size: Number of bytes to read (None for all available)
        framing: How to interpret the response when size is None (see
            connection.framing.Framing). Ignored when size is given.

    Returns:
        Raw binary data

    Raises:
        SiglentConnectionError: If not connected
        SiglentTimeoutError: If read times out
    """
    if not self._connected or not self._socket:
        raise exceptions.SiglentConnectionError(f"Not connected to oscilloscope at {self.host}:{self.port}")

    with self.lock:
        try:
            if size is not None:
                # Read exact number of bytes
                data = b""
                remaining = size
                while remaining > 0:
                    chunk = self._socket.recv(min(remaining, self._buffer_size))
                    if not chunk:
                        self._connected = False
                        raise exceptions.SiglentConnectionError(f"Connection closed by {self.host}:{self.port} after {len(data)} of {size} requested byte(s) following '{self._last_command}'")
                    data += chunk
                    remaining -= len(chunk)
                return data
            else:
                return self._read_ieee_block(framing)
        except socket.timeout:
            self._desynced = True
            command_context = f"after '{self._last_command}' " if self._last_command else ""
            raise exceptions.SiglentTimeoutError(f"Raw read timeout {command_context}from {self.host}:{self.port}")
        except socket.error as e:
            self._connected = False
            command_context = f" after '{self._last_command}'" if self._last_command else ""
            raise exceptions.SiglentConnectionError(f"Read error from {self.host}:{self.port}{command_context}: {e}")

drain_input

drain_input() -> int

Discard bytes already queued on the socket; return the byte count.

Passive by contract (see BaseConnection.drain_input): recv() until the socket goes quiet, and nothing else. It sends nothing, resets nothing, and does not touch _desynced -- "empty the buffer" is not a claim that the session position is known again. A caller that wants THAT wants resync(), which is a different request and says so in its name.

LIMIT, stated plainly: this discards what has ARRIVED. A reply still in flight is indistinguishable from the answer to whatever goes out next, and will still be misattributed.

Source code in scpi_control/connection/socket.py
def drain_input(self) -> int:
    """Discard bytes already queued on the socket; return the byte count.

    Passive by contract (see BaseConnection.drain_input): recv() until the
    socket goes quiet, and nothing else. It sends nothing, resets nothing,
    and does not touch `_desynced` -- "empty the buffer" is not a claim
    that the session position is known again. A caller that wants THAT
    wants resync(), which is a different request and says so in its name.

    LIMIT, stated plainly: this discards what has ARRIVED. A reply still in
    flight is indistinguishable from the answer to whatever goes out next,
    and will still be misattributed.
    """
    if not self._connected or not self._socket:
        return 0
    discarded = 0
    with self.lock:
        previous = self._socket.gettimeout()
        self._socket.settimeout(0.05)
        try:
            while True:
                try:
                    chunk = self._socket.recv(self._buffer_size)
                except socket.timeout:
                    break
                if not chunk:
                    break
                discarded += len(chunk)
        finally:
            self._socket.settimeout(previous if previous is not None else self.timeout)
    return discarded

resync

resync() -> int

Recover a session whose position is unknown; return bytes discarded.

Called automatically before the next send when a read has timed out. The bytes are gone either way -- the alternative is handing them to a caller who asked a different question (High-7).

On this transport recovery IS the drain, so it delegates to drain_input() and then records that the session is back in step. The two still have separate names because they are separate requests, and the other transport answers them differently: VISAConnection.resync() issues a device clear, which aborts whatever the instrument is doing. A caller with a stray terminator to mop up must not be made to ask for that (backend review 2026-07-31 wave 3, whole-branch review).

Inherits drain_input()'s limit: a reply still in flight when the next command goes out cannot be told apart from the answer to that command. Callers that need certainty after a timeout should reconnect, or call resync() themselves once they are willing to wait for the straggler.

Source code in scpi_control/connection/socket.py
def resync(self) -> int:
    """Recover a session whose position is unknown; return bytes discarded.

    Called automatically before the next send when a read has timed out.
    The bytes are gone either way -- the alternative is handing them to a
    caller who asked a different question (High-7).

    On this transport recovery IS the drain, so it delegates to
    drain_input() and then records that the session is back in step. The
    two still have separate names because they are separate requests, and
    the other transport answers them differently: VISAConnection.resync()
    issues a device clear, which aborts whatever the instrument is doing.
    A caller with a stray terminator to mop up must not be made to ask for
    that (backend review 2026-07-31 wave 3, whole-branch review).

    Inherits drain_input()'s limit: a reply still in flight when the next
    command goes out cannot be told apart from the answer to that command.
    Callers that need certainty after a timeout should reconnect, or call
    resync() themselves once they are willing to wait for the straggler.
    """
    if not self._connected or not self._socket:
        self._desynced = False
        return 0
    with self.lock:
        try:
            return self.drain_input()
        finally:
            self._desynced = False

See Also