Skip to content

Oscilloscope

Main oscilloscope control class for SCPI communication

Main Oscilloscope class for controlling Siglent oscilloscopes.

Supports multiple Siglent oscilloscope series including SDS800X HD, SDS1000X-E, SDS2000X Plus, and SDS5000X.

Oscilloscope

Oscilloscope(host: str, port: int = 5025, timeout: float = 5.0, connection: Optional[BaseConnection] = None, dialect: Optional[str] = None)

Main class for controlling Siglent oscilloscopes.

This class provides a high-level interface for controlling oscilloscope functions including channels, triggers, waveform acquisition, and measurements.

Supports multiple Siglent oscilloscope series with automatic model detection and capability-based feature availability.

Example

scope = Oscilloscope('192.168.1.100') scope.connect() print(scope.identify()) print(f"Model: {scope.model_capability.model_name}") print(f"Channels: {scope.model_capability.num_channels}") scope.disconnect()

Or using context manager:

with Oscilloscope('192.168.1.100') as scope: ... print(scope.identify())

Initialize oscilloscope connection.

Parameters:

Name Type Description Default
host str

IP address or hostname of the oscilloscope

required
port int

TCP port for SCPI communication (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
connection Optional[BaseConnection]

Optional custom connection object (uses SocketConnection if None)

None
dialect Optional[str]

Optional SCPI dialect override - "legacy" or "modern". None (default) auto-detects from the model registry. Use "legacy" if a modern-generation scope misbehaves on the colon-form commands.

None
Note

Channels are created dynamically after connection based on model capabilities. Call connect() to establish connection and initialize channels.

Source code in scpi_control/oscilloscope.py
def __init__(
    self,
    host: str,
    port: int = 5025,
    timeout: float = 5.0,
    connection: Optional[BaseConnection] = None,
    dialect: Optional[str] = None,
):
    """Initialize oscilloscope connection.

    Args:
        host: IP address or hostname of the oscilloscope
        port: TCP port for SCPI communication (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)
        connection: Optional custom connection object (uses SocketConnection if None)
        dialect: Optional SCPI dialect override - "legacy" or "modern".
                 None (default) auto-detects from the model registry.
                 Use "legacy" if a modern-generation scope misbehaves on
                 the colon-form commands.

    Note:
        Channels are created dynamically after connection based on model capabilities.
        Call connect() to establish connection and initialize channels.
    """
    self.host = host
    self.port = port
    self.timeout = timeout

    if dialect is not None and dialect not in SUPPORTED_DIALECTS:
        raise exceptions.InvalidParameterError(f"Invalid dialect: {dialect}. Must be one of {SUPPORTED_DIALECTS} or None for auto-detect.")
    self._dialect_override = dialect
    self.dialect: Optional[str] = None

    # Create connection
    if connection is not None:
        self._connection = connection
    else:
        self._connection = SocketConnection(host, port, timeout)

    # Model capability and SCPI commands (populated after connection)
    self.model_capability: Optional[ModelCapability] = None
    self._scpi_commands: Optional[SCPICommandSet] = None
    self._capabilities: Optional[ScopeCapabilities] = None

    # Device information (populated after connection)
    self._device_info: Optional[Dict[str, str]] = None

    # Channels will be created dynamically based on model capability
    # After connection, channels will be available as self.channel1, self.channel2, etc.

    # Channel numbers created by _create_channels(), cleared on disconnect
    self._channel_numbers: List[int] = []

    # Initialize trigger control
    self.trigger = Trigger(self)

    # Initialize waveform acquisition
    self.waveform = Waveform(self)

    # Initialize measurement control
    self.measurement = Measurement(self)

    # Initialize screen capture
    self.screen_capture = ScreenCapture(self)

    # Initialize math channels (available after connection)
    self.math1: Optional[MathChannel] = None
    self.math2: Optional[MathChannel] = None

    # Initialize FFT analyzer
    self.fft_analyzer = FFTAnalyzer()

    # Vector display (lazy-loaded, requires 'fun' extras)
    self._vector_display = None

vector_display property

vector_display

Access vector graphics display functionality.

Requires the 'fun' extras to be installed: pip install "SCPI-Instrument-Control[fun]"

Returns:

Type Description

VectorDisplay instance for XY mode graphics

Raises:

Type Description
ImportError

If 'fun' extras are not installed

Example

scope.vector_display.enable_xy_mode() circle = Shape.circle(radius=0.8) scope.vector_display.draw(circle)

is_connected property

is_connected: bool

Check if connected to oscilloscope.

Returns:

Type Description
bool

True if connected, False otherwise

timebase property writable

timebase: float

Get timebase setting in seconds/division.

device_info property

device_info: Optional[Dict[str, str]]

Get parsed device information.

Returns:

Type Description
Optional[Dict[str, str]]

Dictionary with keys: manufacturer, model, serial, firmware

Optional[Dict[str, str]]

None if not connected

supported_channels property

supported_channels: List[int]

Get list of supported channel numbers for this model.

Returns:

Type Description
List[int]

List of channel numbers (e.g., [1, 2, 3, 4] for 4-channel model)

List[int]

Empty list if not connected

Example

scope.connect() print(scope.supported_channels) [1, 2, 3, 4]

capabilities property

capabilities: ScopeCapabilities

Derived capabilities of the CONNECTED scope (dialect-resolved).

Raises:

Type Description
SiglentConnectionError

before connect()/after disconnect() -- capabilities depend on the resolved dialect; guessing would fabricate support claims.

connect

connect() -> None

Establish connection to the oscilloscope.

This method connects to the oscilloscope, detects the model, and initializes model-specific capabilities and channels.

Raises:

Type Description
SiglentConnectionError

If connection fails

SiglentTimeoutError

If connection times out

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

    This method connects to the oscilloscope, detects the model, and initializes
    model-specific capabilities and channels.

    Raises:
        SiglentConnectionError: If connection fails
        SiglentTimeoutError: If connection times out
    """
    logger.info(f"Connecting to oscilloscope at {self.host}:{self.port}")
    self._connection.connect()
    logger.info("Connected successfully")

    # Verify connection by getting device identification
    try:
        idn_string = self.identify()
        self._device_info = self._parse_idn(idn_string)
        logger.info(f"Connected to: {self._device_info.get('model', 'Unknown')}")

        # Detect model capability
        self.model_capability = detect_model_from_idn(idn_string)
        logger.info(f"Model capability: {self.model_capability}")

        # Resolve wire dialect: explicit override > model registry > legacy
        self.dialect = self._dialect_override or getattr(self.model_capability, "dialect", "legacy")
        self._scpi_commands = SCPICommandSet(self.dialect, self.model_capability.scpi_variant)
        logger.info(f"Using SCPI dialect: {self.dialect} (variant: {self.model_capability.scpi_variant})")
        self._capabilities = build_scope_capabilities(self._scpi_commands, self.model_capability)

        # Per-dialect connect-time setup (e.g. response-header suppression)
        for setup_command in CONNECT_SETUP.get(self.dialect, []):
            self.write(setup_command)

        # Create channels dynamically based on model capability
        self._create_channels()

        # Create math channels
        self._create_math_channels()

        # Update device info with capability information
        self._device_info["series"] = self.model_capability.series
        self._device_info["num_channels"] = str(self.model_capability.num_channels)
        self._device_info["bandwidth_mhz"] = str(self.model_capability.bandwidth_mhz)

    except Exception as e:
        logger.error(f"Failed to identify device or initialize: {e}")
        self.disconnect()
        raise exceptions.SiglentConnectionError(f"Connected but failed to identify device: {e}")

disconnect

disconnect() -> None

Close connection to the oscilloscope.

Source code in scpi_control/oscilloscope.py
def disconnect(self) -> None:
    """Close connection to the oscilloscope."""
    logger.info("Disconnecting from oscilloscope")
    # Release instrument-side state while the link is still up
    try:
        self.measurement.cleanup()
    except Exception as e:
        logger.debug(f"Measurement cleanup skipped: {e}")
    self._connection.disconnect()
    self._device_info = None
    self.model_capability = None
    self._scpi_commands = None
    self._capabilities = None
    self.dialect = None

    # Remove dynamically created channels
    for i in self._channel_numbers:
        channel_attr = f"channel{i}"
        if hasattr(self, channel_attr):
            delattr(self, channel_attr)
    self._channel_numbers = []

    # Clear math channels
    self.math1 = None
    self.math2 = None

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

CommandError

If command contains invalid characters

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

    Args:
        command: SCPI command string

    Raises:
        SiglentConnectionError: If not connected
        CommandError: If command contains invalid characters
    """
    logger.debug(f"Write: {command}")
    self._connection.write(command)

query

query(command: str) -> str

Send a SCPI query and get 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 query times out

CommandError

If command contains invalid characters

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

    Args:
        command: SCPI query command

    Returns:
        Response string from oscilloscope

    Raises:
        SiglentConnectionError: If not connected
        SiglentTimeoutError: If query times out
        CommandError: If command contains invalid characters
    """
    logger.debug(f"Query: {command}")
    response = self._connection.query(command)
    logger.debug(f"Response: {response}")
    return response

read_raw

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

Read raw binary data from oscilloscope.

Parameters:

Name Type Description Default
size Optional[int]

Number of bytes to read (None for all available)

None
framing Framing

What the caller knows the response to be (see connection.framing.Framing). What happens when BOTH size and framing are given is transport-specific, not a uniform "ignored": SocketConnection's exact-size path never reaches the framing code, so framing is genuinely ignored there. MockConnection's BLOCK check instead runs unconditionally, BEFORE size truncation -- a declaration the canned response cannot honour still raises CommandError even with size set, deliberately, so a wrong wire-shape declaration cannot hide behind a truncated read.

AUTO

Returns:

Type Description
bytes

Raw binary data

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

    Args:
        size: Number of bytes to read (None for all available)
        framing: What the caller knows the response to be (see
            connection.framing.Framing). What happens when BOTH size and
            framing are given is transport-specific, not a uniform
            "ignored": SocketConnection's exact-size path never reaches
            the framing code, so framing is genuinely ignored there.
            MockConnection's BLOCK check instead runs unconditionally,
            BEFORE size truncation -- a declaration the canned response
            cannot honour still raises CommandError even with size set,
            deliberately, so a wrong wire-shape declaration cannot hide
            behind a truncated read.

    Returns:
        Raw binary data
    """
    return self._connection.read_raw(size, framing=framing)

identify

identify() -> str

Get device identification string.

Returns:

Type Description
str

Device identification string (manufacturer, model, serial, firmware)

Example

'Siglent Technologies,SDS824X HD,SERIAL123,1.0.0.0'

Source code in scpi_control/oscilloscope.py
def identify(self) -> str:
    """Get device identification string.

    Returns:
        Device identification string (manufacturer, model, serial, firmware)

    Example:
        'Siglent Technologies,SDS824X HD,SERIAL123,1.0.0.0'
    """
    return self.query("*IDN?")

reset

reset() -> None

Reset oscilloscope to default settings.

Note: This may take several seconds to complete.

Source code in scpi_control/oscilloscope.py
def reset(self) -> None:
    """Reset oscilloscope to default settings.

    Note: This may take several seconds to complete.
    """
    logger.info("Resetting oscilloscope to defaults")
    self.write("*RST")

clear_status

clear_status() -> None

Clear status registers.

Source code in scpi_control/oscilloscope.py
def clear_status(self) -> None:
    """Clear status registers."""
    self.write("*CLS")

get_error

get_error() -> str

Unsupported: neither Siglent scope dialect documents an error-queue query.

Legacy scopes use CMR?/EXR? registers; the modern programming guide documents no error queue at all. The old SYST:ERR? implementation always timed out on real hardware.

Source code in scpi_control/oscilloscope.py
def get_error(self) -> str:
    """Unsupported: neither Siglent scope dialect documents an error-queue query.

    Legacy scopes use CMR?/EXR? registers; the modern programming guide
    documents no error queue at all. The old SYST:ERR? implementation
    always timed out on real hardware.
    """
    raise NotImplementedError("No SCPI error-queue query exists on either Siglent scope dialect (legacy scopes expose CMR?/EXR? registers instead).")

wait_complete

wait_complete() -> None

Wait for all pending operations to complete.

Source code in scpi_control/oscilloscope.py
def wait_complete(self) -> None:
    """Wait for all pending operations to complete."""
    self.query("*OPC?")

trigger_single

trigger_single() -> None

Arm a one-shot (single) acquisition.

Source code in scpi_control/oscilloscope.py
def trigger_single(self) -> None:
    """Arm a one-shot (single) acquisition."""
    if self.dialect == "tektronix":
        self.write(self._get_command("set_stop_after", mode="SEQuence"))
        self.write(self._get_command("run"))
    elif self.dialect == "modern":
        # SINGle self-arms on the modern dialect; there is no ARM command (guide p.482)
        self.write(self._get_command("set_trigger_mode", mode="SINGle"))
    else:
        self.write(self._get_command("set_trigger_mode", mode="SINGLE"))
        self.write(self._get_command("arm_trigger"))

trigger_force

trigger_force() -> None

Force a trigger event.

Source code in scpi_control/oscilloscope.py
def trigger_force(self) -> None:
    """Force a trigger event."""
    self.write(self._get_command("force_trigger"))

run

run() -> None

Start acquisition.

Source code in scpi_control/oscilloscope.py
def run(self) -> None:
    """Start acquisition."""
    if self.dialect == "tektronix":
        # A prior single-shot leaves STOPAfter latched to SEQuence
        self.write(self._get_command("set_stop_after", mode="RUNSTop"))
    self.write(self._get_command("run"))

stop

stop() -> None

Stop acquisition.

Source code in scpi_control/oscilloscope.py
def stop(self) -> None:
    """Stop acquisition."""
    self.write(self._get_command("stop"))

acquisition_status

acquisition_status() -> str

Query the acquisition state, normalized across dialects.

Returns:

Type Description
str

One of 'ARM', 'READY', 'AUTO', 'TRIGD', 'STOP', 'ROLL'.

Source code in scpi_control/oscilloscope.py
def acquisition_status(self) -> str:
    """Query the acquisition state, normalized across dialects.

    Returns:
        One of 'ARM', 'READY', 'AUTO', 'TRIGD', 'STOP', 'ROLL'.
    """
    if self.dialect == "lecroy":
        # LeCroy has no SAST-style status query. TRIG_MODE? exposes STOP;
        # INR? bit 0 reports "new signal acquired" (MAUI remote manual).
        mode = self.query(self._get_command("get_trigger_mode")).strip().upper()
        if mode.endswith("STOP"):
            return "STOP"
        inr = int(self.query(self._get_command("get_acq_status")).strip().split()[-1])
        if inr & 1:
            return "TRIGD"
        return "AUTO" if mode.endswith("AUTO") else "READY"
    return normalize_status(self.query(self._get_command("get_acq_status")))

new_acquisition_ready

new_acquisition_ready() -> Optional[bool]

True if a new acquisition has completed since the last check.

Returns None when the active dialect has no way to tell us, which callers must treat as "no gate available" rather than as False -- a False would stall the live view forever on those dialects.

The underlying INR? register is READ-AND-CLEAR: reading it consumes the event. This method is therefore the single permitted consumer. Do not read get_new_data anywhere else, and do not call this method twice per tick expecting the same answer.

Source code in scpi_control/oscilloscope.py
def new_acquisition_ready(self) -> Optional[bool]:
    """True if a new acquisition has completed since the last check.

    Returns None when the active dialect has no way to tell us, which callers
    must treat as "no gate available" rather than as False -- a False would
    stall the live view forever on those dialects.

    The underlying INR? register is READ-AND-CLEAR: reading it consumes the
    event. This method is therefore the single permitted consumer. Do not read
    get_new_data anywhere else, and do not call this method twice per tick
    expecting the same answer.
    """
    if not self._has_command("get_new_data"):
        return None
    try:
        response = self.query(self._get_command("get_new_data"))
        return bool(_parse_inr(response) & 0x01)
    except (SiglentError, ValueError):
        # A gate we cannot read is a gate we do not have, for this tick only.
        return None

record_length

record_length() -> Optional[int]

The full acquisition length in points, or None if the dialect can't say.

This is :ACQuire:POINts?, NOT :WAVeform:MAXPoint? -- the latter is the maximum points a single transfer can carry, not how many the record actually holds. A caller sizing a stride from the wrong one would under-decimate a deep record.

A dialect that MAPS the command is not a promise the instrument will answer it: firmware that doesn't implement the query (or errors on it while stopped) makes it fail, and so does the mock, which has no modern handler for it. A raise here reached the gateway's export path as a 504 on an otherwise healthy session, so this degrades to None -- "the dialect can't say" -- exactly like waveform_max_points() below, and callers already handle None.

Source code in scpi_control/oscilloscope.py
def record_length(self) -> Optional[int]:
    """The full acquisition length in points, or None if the dialect can't say.

    This is :ACQuire:POINts?, NOT :WAVeform:MAXPoint? -- the latter is the
    maximum points a single transfer can carry, not how many the record
    actually holds. A caller sizing a stride from the wrong one would
    under-decimate a deep record.

    A dialect that MAPS the command is not a promise the instrument will
    answer it: firmware that doesn't implement the query (or errors on it
    while stopped) makes it fail, and so does the mock, which has no modern
    handler for it. A raise here reached the gateway's export path as a 504
    on an otherwise healthy session, so this degrades to None -- "the
    dialect can't say" -- exactly like waveform_max_points() below, and
    callers already handle None.
    """
    if not self._has_command("get_acq_points"):
        return None
    try:
        return int(float(self.query(self._get_command("get_acq_points"))))
    except (SiglentError, ValueError):
        return None

waveform_max_points

waveform_max_points() -> Optional[int]

The instrument's per-:WAVeform:DATA?-transfer cap, or None if the dialect can't say.

This is :WAVeform:MAXPoint? -- the same cap ModernTransfer.acquire (waveform_transfer.py) reads before deciding whether a strided record fits in a single window, raising FeatureNotSupportedError when it doesn't. A caller sizing a stride against a frame budget alone, ignoring this number, can turn that guard into a total live-view outage on a model that reports a cap below the frame budget -- size against min(frame_budget, this value) instead.

Source code in scpi_control/oscilloscope.py
def waveform_max_points(self) -> Optional[int]:
    """The instrument's per-:WAVeform:DATA?-transfer cap, or None if the dialect can't say.

    This is :WAVeform:MAXPoint? -- the same cap ModernTransfer.acquire
    (waveform_transfer.py) reads before deciding whether a strided record
    fits in a single window, raising FeatureNotSupportedError when it
    doesn't. A caller sizing a stride against a frame budget alone,
    ignoring this number, can turn that guard into a total live-view
    outage on a model that reports a cap below the frame budget -- size
    against min(frame_budget, this value) instead.
    """
    if not self._has_command("get_waveform_maxpoint"):
        return None
    try:
        value = int(float(self.query(self._get_command("get_waveform_maxpoint"))))
    except (SiglentError, ValueError):
        return None
    return value if value > 0 else None

set_timebase

set_timebase(seconds_per_div: float) -> None

Set timebase (alias for timebase setter).

Source code in scpi_control/oscilloscope.py
def set_timebase(self, seconds_per_div: float) -> None:
    """Set timebase (alias for timebase setter)."""
    self.timebase = seconds_per_div

auto_setup

auto_setup() -> None

Perform automatic setup.

Source code in scpi_control/oscilloscope.py
def auto_setup(self) -> None:
    """Perform automatic setup."""
    self.write(self._get_command("auto_setup"))

get_waveform

get_waveform(channel: int, provenance: bool = True, stride: Optional[int] = None) -> WaveformData

Acquire waveform data from a channel.

Convenience method that calls waveform.acquire().

Parameters:

Name Type Description Default
channel int

Channel number (1-4)

required
provenance bool

Snapshot instrument settings alongside the data (default True; pass False on high-rate paths)

True
stride Optional[int]

Ask the instrument to return every Nth point via :WAVeform:INTerval, bounding the transfer instead of pulling the full record and striding it down afterward. This is instrument state, not a per-request argument: every read sets it explicitly, so None means "set it to 1", never "leave it alone" -- otherwise a stride left over from the live view would silently decimate the next export on this session. Ignored on dialects that don't document the command.

None

Returns:

Type Description
WaveformData

WaveformData object with time and voltage arrays

Source code in scpi_control/oscilloscope.py
def get_waveform(self, channel: int, provenance: bool = True, stride: Optional[int] = None) -> WaveformData:
    """Acquire waveform data from a channel.

    Convenience method that calls waveform.acquire().

    Args:
        channel: Channel number (1-4)
        provenance: Snapshot instrument settings alongside the data
            (default True; pass False on high-rate paths)
        stride: Ask the instrument to return every Nth point via
            :WAVeform:INTerval, bounding the transfer instead of pulling
            the full record and striding it down afterward. This is
            instrument state, not a per-request argument: every read sets
            it explicitly, so None means "set it to 1", never "leave it
            alone" -- otherwise a stride left over from the live view
            would silently decimate the next export on this session.
            Ignored on dialects that don't document the command.

    Returns:
        WaveformData object with time and voltage arrays
    """
    return self.waveform.acquire(channel, provenance=provenance, stride=stride)

get_channel

get_channel(channel_num: int) -> Optional[Channel]

Get channel object by number.

Parameters:

Name Type Description Default
channel_num int

Channel number (1-based)

required

Returns:

Type Description
Optional[Channel]

Channel object or None if channel doesn't exist

Example

scope.connect() ch1 = scope.get_channel(1)

Source code in scpi_control/oscilloscope.py
def get_channel(self, channel_num: int) -> Optional[Channel]:
    """Get channel object by number.

    Args:
        channel_num: Channel number (1-based)

    Returns:
        Channel object or None if channel doesn't exist

    Example:
        >>> scope.connect()
        >>> ch1 = scope.get_channel(1)
    """
    channel_attr = f"channel{channel_num}"
    return getattr(self, channel_attr, None)

See Also

  • Vocabulary - String-compatible enums for token-valued parameters (coupling, trigger mode/slope/source/coupling/type, bandwidth limit, tracking mode) -- enums in, strings out
  • Scope Capabilities - Derived, dialect-resolved capabilities of a connected oscilloscope (scope.capabilities)
  • Channel - Channel configuration and control
  • Trigger - Trigger configuration and modes
  • Waveform - Waveform acquisition and data handling
  • Measurement - Automated measurements (frequency, voltage, timing)
  • Exceptions - Custom exception classes