Repository: pymeasure/pymeasure

Issue:
Allow usage of "VBS" commands for LeCroy / Teledyne oscilloscopes
The "MAUI" class oscilloscopes mentions "VBS" commands:
![image](https://github.com/user-attachments/assets/67bfeaca-1e92-4eaf-b5c8-675318e47ea5)


(The full manual: https://cdn.teledynelecroy.com/files/manuals/maui-remote-control-and-automation-manual.pdf)

Nowhere it is explained clearly what this refers to, but I think it's [VBScript](https://en.wikipedia.org/wiki/VBScript) (Microsoft Visual Basic Scripting Edition)?

I can't find any mention of it in the docs, it would be nice to be included for the Teledyne oscilloscopes.

The Teledyne official package does support it:
```python
        print('Trying to make a connection to ', connection_string)
        dso = LeCroyDSO(ActiveDSO(connection_string))
        
        # ...

        # query the value
        response = dso.query('C1:VDIV?')
        print(response)

        # query it VBS style
        vbs_response = dso.query_vbs('app.Acquisition.C1.VerScale')
        print(vbs_response)
```
([full example](https://lecroydso.readthedocs.io/en/latest/introduction/otherexamples.html))

Discussion:
@BenediktBurger: The most important question is: Can you do it with the normal serial communication or do you need another connection type?

Thanks for your idea.

Generally we are glad for better device usage.@RobertoRoos: I think no other connection is needed. I have a draft ready on my PC, I just need to borrow the company's Teledyne to test it out. If it works I'll create the PR.@BenediktBurger: Ok.
Thanks for improving and expanding pymeasure.

Solution patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 1e2fa3323b..cc6daad3df 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -86,3 +86,4 @@ Konrad Gralher
 David Ziliak
 David Sun
 Kévin Petit
+Orion Smedley
diff --git a/docs/api/instruments/agilent/agilentE5062A.rst b/docs/api/instruments/agilent/agilentE5062A.rst
new file mode 100644
index 0000000000..68b327ddf2
--- /dev/null
+++ b/docs/api/instruments/agilent/agilentE5062A.rst
@@ -0,0 +1,16 @@
+######################################
+Agilent E5062A Vector Network Analyzer
+######################################
+
+.. autoclass:: pymeasure.instruments.agilent.AgilentE5062A
+    :members:
+    :show-inheritance:
+
+.. autoclass:: pymeasure.instruments.agilent.agilentE5062A.VNAChannel
+    :members:
+    :show-inheritance:
+
+.. autoclass:: pymeasure.instruments.agilent.agilentE5062A.VNATrace
+    :members:
+    :show-inheritance:
+
diff --git a/docs/api/instruments/agilent/index.rst b/docs/api/instruments/agilent/index.rst
index 24a42b72cf..029b61141d 100644
--- a/docs/api/instruments/agilent/index.rst
+++ b/docs/api/instruments/agilent/index.rst
@@ -15,6 +15,7 @@ If the instrument you are looking for is not here, also check :doc:`HP<../hp/ind
    agilent8722ES
    agilentE4408B
    agilentE4980
+   agilentE5062A
    agilent34410A
    agilent34450A
    agilent4156
diff --git a/docs/dev/contribute.rst b/docs/dev/contribute.rst
index 3ac07e6ee5..7e88ab9263 100644
--- a/docs/dev/contribute.rst
+++ b/docs/dev/contribute.rst
@@ -13,6 +13,9 @@ New features are added to the development version of PyMeasure, hosted on `GitHu
 .. _Git version control: https://git-scm.com/
 .. _GitHub Desktop: https://git-scm.com/downloads
 
+Make sure :code:`git --version` works from comand line. If you are using Github Desktop, you may need to add Git to your path in each terminal window.
+On windows anaconda prompt it would be :code:`set PATH="C:\Users\<USERNAME>\AppData\Local\GitHubDesktop\app-<#######>\resources\app\git\cmd";%PATH%`
+
 In order to add your feature, you need to first `fork`_ PyMeasure. This will create a copy of the repository under your GitHub account.
 
 .. _fork: https://help.github.com/articles/fork-a-repo/
diff --git a/pymeasure/instruments/agilent/__init__.py b/pymeasure/instruments/agilent/__init__.py
index cc0e1f2339..7772f9aa8d 100644
--- a/pymeasure/instruments/agilent/__init__.py
+++ b/pymeasure/instruments/agilent/__init__.py
@@ -26,6 +26,7 @@
 from .agilent8722ES import Agilent8722ES
 from .agilentE4408B import AgilentE4408B
 from .agilentE4980 import AgilentE4980
+from .agilentE5062A import AgilentE5062A
 from .agilent34410A import Agilent34410A
 from .agilent34450A import Agilent34450A
 from .agilent4156 import Agilent4156
diff --git a/pymeasure/instruments/agilent/agilentE5062A.py b/pymeasure/instruments/agilent/agilentE5062A.py
new file mode 100644
index 0000000000..082b2b5dce
--- /dev/null
+++ b/pymeasure/instruments/agilent/agilentE5062A.py
@@ -0,0 +1,614 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2024 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+from pymeasure.instruments import Instrument, SCPIMixin, Channel
+from pymeasure.instruments.validators import strict_range, strict_discrete_range, \
+    strict_discrete_set
+
+from pyvisa.errors import VisaIOError
+import numpy as np
+
+import functools
+
+
+DISPLAY_LAYOUT_OPTIONS = [
+        "D1",
+        "D12",
+        "D1_2",
+        "D112",
+        "D1_1_2",
+        "D123",
+        "D1_2_3",
+        "D12_33",
+        "D11_23",
+        "D13_23",
+        "D12_13",
+        "D1234",
+        "D1_2_3_4",
+        "D12_34"
+]
+
+
+class VNATrace(Channel):
+    """A trace (of a channel) of the E5061A/E5062A"""
+
+    placeholder = 'tr'      # default ch would collide with VNAChannel
+
+    def activate(self):
+        """Select this trace. """
+        self.write('CALC{{ch}}:PAR{tr}:SEL')
+
+    parameter = Channel.control(
+        "CALCulate{{ch}}:PARameter{tr}:DEFine?",
+        "CALCulate{{ch}}:PARameter{tr}:DEFine %s",
+        """Control the measurement parameter of the trace (str). Can be
+        S11, S21, S12, or S22""",
+        validator=strict_discrete_set,
+        values=['S11', 'S12', 'S21', 'S22']
+    )
+
+
+class VNAChannel(Channel):
+    """A measurement channel of the E5061A/E5062A"""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._add_trace(override_id=1)                 # add just a single trace to init it all
+        self._update_trace_count(self.visible_traces)  # add remaining traces if necessary
+        self._update_power_values(self.attenuation)    # update dynamic limits of power
+
+    def _add_trace(self, override_id=None):
+        """Internal method that adds a trace to the traces list"""
+        tr_id = len(self.traces) + 1 if override_id is None else override_id
+        self.add_child(VNATrace, tr_id, 'traces', 'tr_')
+
+    def _update_trace_count(self, visible_traces):
+        """Internal method that updates the trace list based on the number
+        of visible traces.
+        """
+        while len(self.traces) > visible_traces:
+            self.remove_child(self.traces[max(self.traces.keys())])
+        while len(self.traces) < visible_traces:
+            self._add_trace()
+
+    def _update_power_values(self, attenuation):
+        """Internal method that updates the valid range of stimulus power
+        based on the configured attenuation"""
+        self.power_values = (
+            -attenuation - 5,
+            -attenuation + 10)
+
+    start_frequency = Channel.control(
+        "SENSe{ch}:FREQuency:STARt?", "SENSe{ch}:FREQuency:STARt %g",
+        """Control the start frequency in Hz (float).""",
+        validator=strict_range,
+        values=[3e5, 3e9]
+    )
+
+    stop_frequency = Channel.control(
+        "SENSe{ch}:FREQuency:STOP?", "SENSe{ch}:FREQuency:STOP %g",
+        """Control the stop frequency in Hz (float).""",
+        validator=strict_range,
+        values=[3e5, 3e9]
+    )
+
+    scan_points = Channel.control(
+        "SENSe{ch}:SWEep:POINts?", "SENSe{ch}:SWEep:POINts %g",
+        """Control the number of points used in a sweep (int). Valid range 2 - 1601.""",
+        cast=int,
+        validator=functools.partial(strict_discrete_range, step=1),
+        values=range(2, 1602)
+    )
+
+    sweep_time = Channel.control(
+        "SENSe{ch}:SWEep:TIME?", "SENSe{ch}:SWEep:TIME %g",
+        """Control the sweep time in seconds (float). The allowable range
+        varies on config and the set value is truncated. Note that
+        ``sweep_time_auto_enabled`` needs to be ``False`` for changes to this
+        property to have an effect."""
+    )
+
+    sweep_time_auto_enabled = Channel.control(
+        "SENSe{ch}:SWEep:TIME:AUTO?", "SENSe{ch}:SWEep:TIME:AUTO %d",
+        """Control whether to automatically set the sweep time (bool). You
+        probably want this on to always keep the sweep time to a minimum (given
+        the range, IF BW, and sweep delay time).""",
+        validator=strict_discrete_set,
+        map_values=True,
+        values={True: 1, False: 0}
+    )
+
+    sweep_type = Channel.control(
+        "SENSe{ch}:SWEep:TYPE?", "SENSe{ch}:SWEep:TYPE %s",
+        """Control the type of the sweep (string).
+
+        =======  ===========
+        Setting  Description
+        =======  ===========
+        LIN      Linear
+        LOG      Logarithmic
+        SEGM     Segment
+        POW      Power
+        =======  ===========
+
+        Defaults to linear. Note that the API for configuring segment type
+        sweeps is not implememented in this class.""",
+        validator=strict_discrete_set,
+        values=['LIN', 'LOG', 'SEGM', 'POW']
+    )
+
+    averaging_enabled = Channel.control(
+        "SENSe{ch}:AVERage?", "SENSe{ch}:AVERage %d",
+        """Control whether to average the measurement data (bool).""",
+        validator=strict_discrete_set,
+        map_values=True,
+        values={True: 1, False: 0}
+    )
+
+    def restart_averaging(self):
+        """
+        Clear averaging history.
+        """
+        self.write("SENSe{ch}:AVERage:CLEar")
+
+    averages = Channel.control(
+        "SENSe{ch}:AVERage:COUNt?", "SENSe{ch}:AVERage:COUNt %d",
+        """Control how many averages to take, from 1-999 (int). Note that
+        ``averaging_enabled`` needs to be true for averaging to be enabled.""",
+        cast=lambda x: int(float(x)),
+        validator=strict_range,
+        values=[1, 999]
+    )
+
+    IF_bandwidth = Channel.control(
+        "SENSe{ch}:BANDwidth?", "SENSe{ch}:BANDwidth %d",
+        """Control the IF bandwidth in Hz (int), from 10 Hz to 30 kHz. Default
+        30 kHz.""",
+        cast=lambda x: int(float(x)),
+        validator=strict_range,
+        values=[10, 30000]
+    )
+
+    def activate(self):
+        """Activate (select) the current channel. Note that the channel
+        must first be displayed (via AgilentE5062A.display_layout) for it
+        to be activatable.
+
+        """
+        if str(self.id) not in self.parent.display_layout:
+            raise ValueError('Cannot activate a channel that is not displayed')
+        self.write("DISP:WIND{ch}:ACT")
+
+    @property
+    def visible_traces(self):
+        """Control the number of traces visible in the channel (int)."""
+        return int(self.ask("CALC{ch}:PARameter:COUNt?"))
+
+    @visible_traces.setter
+    def visible_traces(self, value):
+        value = int(float(value))
+        value = strict_range(value, [1, 4])
+        self.write("CALC{ch}:PARameter:COUNt %d" % value)
+        self._update_trace_count(value)
+
+    power = Channel.control(
+        "SOURce{ch}:POWer?", "SOURce{ch}:POWer %g",
+        """Control the simulus power in dBm (float). The allowable range is
+        influenced by the value of ``attenuation``. """,
+        validator=strict_range,
+        values=(-5, 10),
+        dynamic=True,
+    )
+
+    @property
+    def attenuation(self):
+        """Control the stimulus attenuation in dB (positive int), from 0 to 40
+        in incrememnts of 10. Default is 0 dB. The allowable stimulus power
+        range is a 15 dB range: (``attenuation`` - 5 dB, ``attenuation`` + 10
+        dB).
+
+        This requires the power range extension, and the command is ignored
+        if the extension is not installed.
+
+        """
+        return int(float(self.ask("SOURce{ch}:POWer:ATTenuation?")))
+
+    @attenuation.setter
+    def attenuation(self, value):
+        value = int(float(value))
+        value = strict_discrete_set(value, [0, 10, 20, 30, 40])
+        self.write("SOURce{ch}:POWer:ATTenuation %d" % value)
+        self._update_power_values(value)
+
+    display_layout = Channel.control(
+        "DISPlay:WINDow{ch}:SPLit?", "DISPlay:WINDow{ch}:SPLit %s",
+        """Control the graph layout of the traces in the channel (str). Does
+        not affect how many traces are active. See the list of valid options:
+
+        - D1
+        - D12
+        - D1_2
+        - D112
+        - D1_1_2
+        - D123
+        - D1_2_3
+        - D12_33
+        - D11_23
+        - D13_23
+        - D12_13
+        - D1234
+        - D1_2_3_4
+        - D12_34
+
+        In general, an occurance of a number denotes the associated trace
+        having it's own subplot and an occurence of '_' denotes a vertical
+        split. Multiple occurences of the same number denote the trace
+        having a larger window than the other traces. Refer to Figure 3-1
+        in the the programmer's manual for details. If a trace is active
+        but it's associated number is not present in the ``display_layout``
+        identifier, it is plotted in the first subplot.
+
+        """,
+        validator=strict_discrete_set,
+        values=DISPLAY_LAYOUT_OPTIONS
+    )
+
+    TRACE_FORMAT = [
+        'MLOG',
+        'PHAS',
+        'GDEL',
+        'SLIN',
+        'SLOG',
+        'SCOM',
+        'SMIT',
+        'SADM',
+        'PLIN',
+        'PLOG',
+        'POL',
+        'MLIN',
+        'SWR',
+        'REAL',
+        'IMAG',
+        'UPH',
+        'PPH'
+    ]
+
+    trace_format = Channel.control(
+        "CALCulate{ch}:FORMat?",
+        "CALCulate{ch}:FORMat %s",
+        """Control the data format of the *active trace* of the channel
+        (str). Default is MLOGarithmic. From the programmer's manual:
+
+        =======  =============================================
+        Setting  Description
+        =======  =============================================
+        MLOG     Specifies the logarithmic magnitude format.
+        PHAS     Specifies the phase format.
+        GDEL     Specifies the group delay format.
+        SLIN     Specifies the Smith chart format (Lin/Phase).
+        SLOG     Specifies the Smith chart format (Log/Phase).
+        SCOM     Specifies the Smith chart format (Real/Imag).
+        SMIT     Specifies the Smith chart format (R+jX).
+        SADM     Specifies the Smith chart format (G+jB).
+        PLIN     Specifies the polar format (Lin).
+        PLOG     Specifies the polar format (Log).
+        POr      Specifies the polar format (Re/Im).
+        MLI      Specifies the linear magnitude format.
+        SWR      Specifies the SWR format.
+        REAL     Specifies the real format.
+        IMAG     Specifies the imaginary format.
+        UPH      Specifies the expanded phase format.
+        PPH      Specifies the positive phase format.
+        =======  =============================================
+
+        """,
+        validator=strict_discrete_set,
+        values=TRACE_FORMAT
+        )
+
+    def _read_binary_data(self):
+        """Internal method that reads and interprents binary data arrays as
+        floats. Assumes the query has already been sent.
+
+        Uses the IEEE 64-bit fp transer format (little-endian).
+
+        """
+        header = self.read_bytes(8)  # 2 start bytes + 6 <nbytes> bytes
+        if not header[0:2].decode('ascii') == '#6':
+            raise ValueError('''Unrecognized header. Ensure the data transfer
+            format (FORM:DATA?) is set to "REAL"''')
+        nbytes = int(header[2:].decode('ascii'))
+        binary_data = self.read_bytes(nbytes)
+        terminator = self.read_bytes(1)
+        if terminator.decode('ascii') != '\n':
+            raise ValueError(f'Unexpected terminator {terminator} (expected \\n)')
+        return np.frombuffer(binary_data, dtype='<f8')
+
+    @property
+    def data(self):
+        """Measure the Formatted Data of the *active trace*.
+
+        Each trace consists of ``scan_points`` plotted either vs. frequency
+        or in something like a smith-chart configuration. This property
+        does not access any frequency information. So for rectangular
+        plots, this query returns only the y-values of the trace (no
+        frequency information). For smith- and polar- plots, this two
+        values per data point.
+
+        This function returns a tuple containing both a primary and a
+        secondary data numpy array. The secondary data is all zeros for all
+        trace formats that are not smith or polar. The implication for this
+        is that the best way to save complex S-parameter data in one go is
+        to use a Smith (Real/Imag) or Polar (Real/Imag) trace format,
+        (SCOMplex and POLar, respectively).
+
+        The formatted data array is settable in the VNA but not implemented
+        in this python API.
+
+        Frequency data is accessed via the ``frequencies`` property.
+
+        .. code-block:: python
+
+            # build an s-parameter matrix by measuring the trace data,
+            # where s_matrix[i] = [[S11, S12], [S21, S22]] @ freqs[i]
+            freqs = ch.frequencies
+            s_matrix = np.empty((freqs.size, 4), dtype=np.complex64)
+            for i, tr in enumerate(ch.traces.values()):
+                tr.activate()
+                ch.trace_format = 'POL'
+                re, im = ch.data
+                s_matrix[:, i] = re + 1j * im
+            s_matrix = s_matrix.reshape(-1, 2, 2)  # ready for use with e.g. skrf
+
+        """
+        self.write(f'CALC{self.id}:DATA:FDAT?')
+        numeric_data = self._read_binary_data()
+        primary_data = numeric_data[::2]
+        secondary_data = numeric_data[1::2]
+        return primary_data, secondary_data
+
+    @property
+    def frequencies(self):
+        """Measure the frequency in Hz associated with each data point of the
+        *active trace*. Returns a numpy array.
+
+        """
+        self.write(f'SENS{self.id}:FREQ:DATA?')
+        return self._read_binary_data()
+
+    trigger_continuous = Instrument.control(
+        "INITiate{ch}:CONTinuous?", "INITiate{ch}:CONTinuous %d",
+        """Control whether the channel triggers continuously (bool). If
+        not, after a sweep is complete, the global trigger enters the hold
+        state, and will not respond to triggers.  """,
+        validator=strict_discrete_set,
+        map_values=True,
+        values={True: 1, False: 0}
+    )
+
+    def trigger_initiate(self):
+        """If the trigger is in the hold state (i.e. ``trigger_continuous``
+        is off and the measurement has completed), re-initialize the
+        trigger for a single measurement.
+
+        """
+        self.write(f'INITiate{self.id}')
+
+
+class AgilentE5062A(SCPIMixin, Instrument):
+    """Represents the Agilent E5062A Vector Network Analyzer
+
+    This VNA has 4 separate channels, each of which has its own sweep. The
+    channels are stored in the ``channels`` dictionary. Channels can be enabled
+    even if not displayed. Many trace-specific operations, including reading
+    out data, happen at the channel level, but pertain to only the *active
+    trace*.
+
+    Each channel can display up to 4 traces (controlled via the
+    ``visible_traces`` parameter). Each channel also has a ``display_layout``
+    property that controls the layout of the traces in the channel. The traces
+    are accessed via the ``traces`` dictionary
+    (i.e. ``vna.channels[1].traces[1]``).
+
+    The VNA supports multiple transfer formats. This API only supports the IEEE
+    64-bit floating point format. The VNA is configured for this format during
+    initialization, and the data transfer format is not a publicly accessible
+    property.
+
+    This class implements only a subset of the total E5062A functionality. The
+    more significant missing functionality includes (TODO):
+
+    - editing the scale of the display
+    - markers
+    - calibration
+    - power sweeps
+
+    .. code-block:: python
+
+        # connect to the VNA and make a measurement consisting of 10 averages
+        vna = AgilentE5062A("TCPIP::192.168.2.233::INSTR")
+        ch = vna.channels[1]            # use channel 1
+        ch.visible_traces = 4            # use all 4 traces
+        for i, (tr, parameter) in enumerate(zip(
+                ch.traces.values(),
+                ['S11', 'S12', 'S21', 'S22'])):
+            tr.parameter = parameter
+
+        ch.averages = 10                # use 10x averaging
+        ch.averaging_enabled = True     # turn averaging on
+        ch.trigger_continuous = False   # turn off continuous triggering
+        vna.trigger_source = 'BUS'      # require remote trigger
+        vna.abort()                     # stop the current sweep
+        ch.restart_averaging()          # clear current averaging
+        for _ in range(ch.averages):
+            ch.trigger_initiate()       # arm channel 1 for single sweep
+            vna.trigger_single()        # send a trigger
+            vna.wait_for_complete()     # wait until the sweep is complete
+
+        # see `agilentE5062A.data` for an example of saving the measurement
+
+    """
+
+    ch_1 = Instrument.ChannelCreator(VNAChannel, 1)
+    ch_2 = Instrument.ChannelCreator(VNAChannel, 2)
+    ch_3 = Instrument.ChannelCreator(VNAChannel, 3)
+    ch_4 = Instrument.ChannelCreator(VNAChannel, 4)
+
+    def __init__(
+            self,
+            adapter,
+            name="Agilent E5062A Vector Network Analyzer",
+            **kwargs):
+        super().__init__(
+            adapter,
+            name,
+            **kwargs
+        )
+        self.write("FORMat:DATA REAL")  # set data transfer format to IEEE fp64
+        self.write("FORMat:BORDer SWAPped")  # ensure byte-order is little-endian
+
+    display_layout = Instrument.control(
+        "DISPlay:SPLit?", "DISPlay:SPLit %s",
+        """Control the layout of the windows (channels) on the display
+        (str). See the list of valid options below:
+
+        - D1
+        - D12
+        - D1_2
+        - D112
+        - D1_1_2
+        - D123
+        - D1_2_3
+        - D12_33
+        - D11_23
+        - D13_23
+        - D12_13
+        - D1234
+        - D1_2_3_4
+        - D12_34
+
+        In general, an occurance of a number denotes the associated channel
+        being visible and an occurence of '_' denotes a vertical
+        split. Multiple occurences of the same number denote the channel having
+        a larger window than the other channels. Refer to Figure 3-1 in the the
+        programmer's manual for details.
+
+        Note that this splits windows for multiple *channels*, not
+        traces. Basic S-param measurements most likely want to use only a
+        single channel, but with multuple *traces* instead. See
+        e.g. ``AgilentE5062A.channels[1].visible_traces``
+        """,
+        validator=strict_discrete_set,
+        values=DISPLAY_LAYOUT_OPTIONS
+    )
+
+    output_enabled = Instrument.control(
+        "OUTPut?", "OUTPUT %d",
+        """Control whether to turn on the RF stimulus (bool). The stimulus
+        needs to be on to perform any measurement. Beware that the stimulus is
+        on by default! (i.e. after reset())""",
+        validator=strict_discrete_set,
+        map_values=True,
+        values={True: 1, False: 0}
+    )
+
+    def abort(self):
+        """Abort the current sweep (affects the trigger fsm, see page 80 of the
+        programmer's manual for details)
+
+        """
+        self.write(":ABOR")
+
+    trigger_source = Instrument.control(
+        "TRIGger:SOURce?", "TRIGger:SOURce %s",
+        """Control the trigger source (str). From the documentation:
+
+        - INT: Uses the internal trigger to generate continuous triggers
+          automatically (default).
+        - EXT: Generates a trigger when the trigger signal is inputted
+          externally via the Ext Trig connector or the handler interface.
+        - MAN: Generates a trigger when the key operation of [Trigger] -
+          Trigger is executed from the front panel.
+        - BUS: Generates a trigger when the ``*TRG`` command is executed.
+
+        """,
+        validator=strict_discrete_set,
+        values=[
+            'INT',
+            'EXT',
+            'MAN',
+            'BUS'])
+
+    def trigger_bus(self):
+        """If the trigger source is BUS and the VNA is waiting for a trigger
+        (the trigger has been initialized), generate a trigger.
+
+        """
+        self.write('*TRG')
+
+    def trigger(self):
+        """Regardless of the trigger setting, generate a trigger (if the
+        trigger has been initialized, i.e. is in the trigger wait state)
+
+        """
+        self.write('TRIGger')
+
+    def trigger_single(self):
+        """like trigger(), but the `complete` SCPI property (synchronization
+        bit) waits for the sweep to be complete (see
+        `agilentE5062A.wait_for_complete()`).
+
+        """
+        self.write('TRIGger:SINGle')
+
+    def wait_for_complete(self, attempt=1, max_attempts=20):
+        """Wait a potentially long time for the synchronization bit.  This is
+        useful in conjunction with `agilentE5062A.trigger_single()` to wait for
+        a single sweep to complete.
+
+        """
+        try:
+            self.complete
+        except VisaIOError:
+            if attempt == max_attempts:
+                raise
+            else:
+                self.wait_for_complete(
+                    attempt=attempt+1,
+                    max_attempts=max_attempts)
+
+    def pop_err(self):
+
+        """Pop an error off the error queue. Returns a tuple containing the
+        code and error description. An error code 0 indicates success.
+
+        The Error queue can be cleared using the standard SCPI
+        ``agilentE5062A.clear()`` method.
+
+        """
+        error_str = self.ask('SYST:ERR?').strip()
+        error_code_str, error_desc_str = error_str.split(',')
+        error_code = int(error_code_str)
+        error_desc = error_desc_str.strip('"')
+        return error_code, error_desc
diff --git a/pymeasure/instruments/teledyne/teledyneMAUI.py b/pymeasure/instruments/teledyne/teledyneMAUI.py
index 1160c465a0..c8e9decc34 100644
--- a/pymeasure/instruments/teledyne/teledyneMAUI.py
+++ b/pymeasure/instruments/teledyne/teledyneMAUI.py
@@ -101,8 +101,11 @@ class TeledyneMAUI(TeledyneOscilloscope):
     The manual detailing the API is "MAUI Oscilloscopes Remote Control and Automation Manual"
     (`link`_).
 
+    This class of Teledyne oscilloscopes also support direct VBS commands.
+    See :meth:`~vbs_ask` and :meth:`~vbs_write`.
+
     .. _link: https://cdn.teledynelecroy.com/files/manuals/
-              maui-remote-control-automation_27jul22.pdf
+              maui-remote-control-and-automation-manual.pdf
     """
 
     ch_1 = Instrument.ChannelCreator(TeledyneMAUIChannel, 1)
@@ -116,6 +119,40 @@ class TeledyneMAUI(TeledyneOscilloscope):
     # Change listed values for existing commands:
     bwlimit_values = TeledyneMAUIChannel.BANDWIDTH_LIMITS
 
+    def vbs_write(self, message: str):
+        """Write a VBS command directly to the device.
+
+        This class of oscilloscopes also allows the direct usage of Visual Basic
+        Scripting (VBScript). With this method a literal VBS command is sent.
+        You can use the 'MAUI Browser' on the oscilloscope to list all available
+        variables.
+
+        A very basic example of usage:
+
+        .. code:: python
+
+           instrument.vbs_write("app.Display.GridMode = Dual")
+        """
+        query = f"VBS '{message}'"
+        self.write(query)
+
+    def vbs_ask(self, name: str) -> str:
+        """Return the value of a VBS variable.
+
+        Only the target needs to be specified, a query is formatted by this method.
+        Note: the target name is not escaped!
+
+        See :meth:`~vbs_write` for more info.
+
+        A very basic example of usage:
+
+        .. code:: python
+
+           instrument.vbs_ask("app.Display.GridMode")
+        """
+        query = f"VBS? 'Return={name}'"
+        return self.ask(query)
+
     ###############
     #   Trigger   #
     ###############


Test patch:
diff --git a/tests/instruments/agilent/test_agilentE5062A.py b/tests/instruments/agilent/test_agilentE5062A.py
new file mode 100644
index 0000000000..7cccf7e31f
--- /dev/null
+++ b/tests/instruments/agilent/test_agilentE5062A.py
@@ -0,0 +1,486 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2024 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+import pytest
+
+from pymeasure.test import expected_protocol
+
+from pymeasure.instruments.agilent.agilentE5062A import AgilentE5062A
+
+import numpy as np
+
+DISPLAY_LAYOUT_OPTIONS = [
+        "D1",
+        "D12",
+        "D1_2",
+        "D112",
+        "D1_1_2",
+        "D123",
+        "D1_2_3",
+        "D12_33",
+        "D11_23",
+        "D13_23",
+        "D12_13",
+        "D1234",
+        "D1_2_3_4",
+        "D12_34"
+]
+
+
+def initial_comm_pairs():
+    """during initialization of the AgilentE5062A class, we need to set (&
+    query) a few things from the VNA as part of setup. This function returns
+    that initial communication.
+
+    """
+    comms = []
+    for ch in 1 + np.arange(4):
+        comms += [
+            (f"CALC{ch}:PARameter:COUNt?", "1"),
+            (f"SOURce{ch}:POWer:ATTenuation?", "0"),
+        ]
+    comms += [
+        ("FORMat:DATA REAL", None),
+        ("FORMat:BORDer SWAPped", None)
+    ]
+    return comms
+
+
+def test_ch_visible_traces():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("CALC1:PARameter:COUNt 1", None),
+                ("CALC1:PARameter:COUNt?", "1"),
+                ("CALC1:PARameter:COUNt 2", None),
+                ("CALC1:PARameter:COUNt?", "2"),
+                ("CALC1:PARameter:COUNt 3", None),
+                ("CALC1:PARameter:COUNt?", "3"),
+                ("CALC1:PARameter:COUNt 4", None),
+                ("CALC1:PARameter:COUNt?", "4")
+            ]) as inst:
+        # test ch visible_traces
+        ot4 = np.arange(4) + 1
+        ch = inst.channels[1]
+        for opt in ot4:
+            ch.visible_traces = opt
+            assert ch.visible_traces == opt
+
+
+def test_ch_traces():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("CALCulate1:PARameter1:DEFine S11", None),
+                ("CALCulate1:PARameter1:DEFine?", "S11"),
+                ("CALCulate1:PARameter1:DEFine S12", None),
+                ("CALCulate1:PARameter1:DEFine?", "S12"),
+                ("CALCulate1:PARameter1:DEFine S21", None),
+                ("CALCulate1:PARameter1:DEFine?", "S21"),
+                ("CALCulate1:PARameter1:DEFine S22", None),
+                ("CALCulate1:PARameter1:DEFine?", "S22"),
+                ("CALC1:PAR1:SEL", None)
+            ]) as inst:
+        tr = inst.channels[1].traces[1]
+        for p in ['S11', 'S12', 'S21', 'S22']:
+            tr.parameter = p
+            assert tr.parameter == p
+        tr.activate()
+
+
+def test_ch_start_frequency():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:FREQuency:STARt 300000", None),
+                ("SENSe1:FREQuency:STARt?", "300000"),
+                ("SENSe1:FREQuency:STARt 3e+09", None),
+                ("SENSe1:FREQuency:STARt?", "3000000000"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for f in [3e5, 3e9]:
+            ch.start_frequency = f
+            assert ch.start_frequency == f
+
+
+def test_ch_stop_frequency():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:FREQuency:STOP 300000", None),
+                ("SENSe1:FREQuency:STOP?", "300000"),
+                ("SENSe1:FREQuency:STOP 3e+09", None),
+                ("SENSe1:FREQuency:STOP?", "3000000000"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for f in [3e5, 3e9]:
+            ch.stop_frequency = f
+            assert ch.stop_frequency == f
+
+
+def test_ch_scan_points():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:SWEep:POINts 2", None),
+                ("SENSe1:SWEep:POINts?", "2"),
+                ("SENSe1:SWEep:POINts 1601", None),
+                ("SENSe1:SWEep:POINts?", "1601"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for p in [2, 1601]:
+            ch.scan_points = p
+            assert ch.scan_points == p
+
+
+def test_ch_sweep_time():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:SWEep:TIME:AUTO 0", None),
+                ("SENSe1:SWEep:TIME:AUTO?", "0"),
+                ("SENSe1:SWEep:TIME 1", None),
+                ("SENSe1:SWEep:TIME?", "1"),
+                ("SENSe1:SWEep:TIME 2", None),
+                ("SENSe1:SWEep:TIME?", "2"),
+                ("SENSe1:SWEep:TIME:AUTO 1", None),
+                ("SENSe1:SWEep:TIME:AUTO?", "1")
+            ]) as inst:
+        ch = inst.channels[1]
+        ch.sweep_time_auto_enabled = False
+        assert not ch.sweep_time_auto_enabled
+        for t in [1, 2]:
+            ch.sweep_time = t
+            assert np.isclose(ch.sweep_time, t, rtol=.01)
+        ch.sweep_time_auto_enabled = True
+        assert ch.sweep_time_auto_enabled
+
+
+def test_ch_sweep_type():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:SWEep:TYPE LIN", None),
+                ("SENSe1:SWEep:TYPE?", "LIN"),
+                ("SENSe1:SWEep:TYPE LOG", None),
+                ("SENSe1:SWEep:TYPE?", "LOG"),
+                ("SENSe1:SWEep:TYPE SEGM", None),
+                ("SENSe1:SWEep:TYPE?", "SEGM"),
+                ("SENSe1:SWEep:TYPE POW", None),
+                ("SENSe1:SWEep:TYPE?", "POW"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for t in ['LIN', 'LOG', 'SEGM', 'POW']:
+            ch.sweep_type = t
+            assert ch.sweep_type == t  # returns just the capitalized part
+
+
+def test_ch_averaging():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:AVERage 1", None),
+                ("SENSe1:AVERage?", "1"),
+                ("SENSe1:AVERage:CLEar", None),
+                ("SENSe1:AVERage:COUNt 1", None),
+                ("SENSe1:AVERage:COUNt?", "1"),
+                ("SENSe1:AVERage:COUNt 999", None),
+                ("SENSe1:AVERage:COUNt?", "999"),
+                ("SENSe1:AVERage 0", None),
+                ("SENSe1:AVERage?", "0")
+            ]) as inst:
+        ch = inst.channels[1]
+        ch.averaging_enabled = True
+        assert ch.averaging_enabled
+        ch.restart_averaging()
+        for n in [1, 999]:
+            ch.averages = n
+            assert ch.averages == n
+        ch.averaging_enabled = False
+        assert not ch.averaging_enabled
+
+
+def test_ch_IF_bandwidth():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENSe1:BANDwidth 10", None),
+                ("SENSe1:BANDwidth?", "10"),
+                ("SENSe1:BANDwidth 30000", None),
+                ("SENSe1:BANDwidth?", "30000")
+            ]) as inst:
+        ch = inst.channels[1]
+        for bw in [10, 30e3]:
+            ch.IF_bandwidth = bw
+            assert ch.IF_bandwidth == bw
+
+
+def test_ch_display_layout():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("DISPlay:SPLit D1", None),
+                ("DISPlay:SPLit?", "D1"),
+                ("DISPlay:SPLit D12_34", None),
+                ("DISPlay:SPLit?", "D12_34")
+            ]) as inst:
+        for layout in ["D1", "D12_34"]:
+            inst.display_layout = layout
+            assert inst.display_layout == layout
+
+
+def test_attenuation():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SOURce1:POWer:ATTenuation 0", None),
+                ("SOURce1:POWer:ATTenuation?", "0"),
+                ("SOURce1:POWer -5", None),
+                ("SOURce1:POWer?", "-5"),
+                ("SOURce1:POWer 10", None),
+                ("SOURce1:POWer?", "10"),
+                ("SOURce1:POWer:ATTenuation 40", None),
+                ("SOURce1:POWer:ATTenuation?", "40"),
+                ("SOURce1:POWer -45", None),
+                ("SOURce1:POWer?", "-45"),
+                ("SOURce1:POWer -30", None),
+                ("SOURce1:POWer?", "-30"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for atten in [0, 40]:
+            ch.attenuation = atten
+            assert ch.attenuation == atten
+            for p in [-5, 10]:
+                power = -atten + p
+                ch.power = power
+                assert ch.power == power
+
+
+def test_tr_display_layout():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("DISPlay:WINDow1:SPLit D1", None),
+                ("DISPlay:WINDow1:SPLit?", "D1"),
+                ("DISPlay:WINDow1:SPLit D12_34", None),
+                ("DISPlay:WINDow1:SPLit?", "D12_34")
+            ]) as inst:
+        ch = inst.channels[1]
+        for layout in ['D1', 'D12_34']:
+            ch.display_layout = layout
+            assert ch.display_layout == layout
+
+
+def test_ch_activate_correct():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("DISPlay:SPLit D1_2", None),
+                ("DISPlay:SPLit?", "D1_2"),  # for a check
+                ("DISP:WIND2:ACT", None),
+                ("DISPlay:SPLit D1", None),
+            ]) as inst:
+        inst.display_layout = 'D1_2'
+        inst.channels[2].activate()
+        inst.display_layout = 'D1'
+
+
+def test_ch_activate_incorrect():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("DISPlay:SPLit D1", None),
+                ("DISPlay:SPLit?", None)  # for a check
+            ]) as inst:
+        inst.display_layout = 'D1'
+        with pytest.raises(ValueError):
+            inst.channels[2].activate()
+
+
+def test_tr_format():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("CALCulate1:FORMat MLOG", None),
+                ("CALCulate1:FORMat?", "MLOG"),
+                ("CALCulate1:FORMat PPH", None),
+                ("CALCulate1:FORMat?", "PPH"),
+            ]) as inst:
+        ch = inst.channels[1]
+        for opt in ["MLOG", "PPH"]:
+            ch.trace_format = opt
+            assert ch.trace_format == opt
+
+
+def test_data():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("CALC1:DATA:FDAT?", None),
+                (None, bytes('#6003216', encoding='ascii')),
+                (None, bytes(3216)),
+                (None, bytes('\n', encoding='ascii'))
+            ]) as inst:
+        real, imag = inst.channels[1].data
+        assert np.size(real) == 201
+        assert np.size(imag) == 201
+
+
+def test_frequencies():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("SENS1:FREQ:DATA?", None),
+                (None, bytes('#6001608', encoding='ascii')),
+                (None, bytes(1608)),
+                (None, bytes('\n', encoding='ascii'))
+            ]) as inst:
+        freqs = inst.channels[1].frequencies
+        assert np.size(freqs) == 201
+
+
+def test_reset():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("*RST", None)
+            ]) as inst:
+        inst.reset()
+
+
+def test_abort():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                (":ABOR", None)
+            ]) as inst:
+        inst.abort()
+
+
+def test_trigger_source():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("TRIGger:SOURce INT", None),
+                ("TRIGger:SOURce?", "INT"),
+                ("TRIGger:SOURce EXT", None),
+                ("TRIGger:SOURce?", "EXT"),
+                ("TRIGger:SOURce MAN", None),
+                ("TRIGger:SOURce?", "MAN"),
+                ("TRIGger:SOURce BUS", None),
+                ("TRIGger:SOURce?", "BUS")
+            ]) as inst:
+        for src in ['INT', 'EXT', 'MAN', 'BUS']:
+            inst.trigger_source = src
+            assert inst.trigger_source == src
+
+
+def test_trigger_continuous():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("INITiate1:CONTinuous 0", None),
+                ("INITiate1:CONTinuous?", "0"),
+                ("INITiate1:CONTinuous 1", None),
+                ("INITiate1:CONTinuous?", "1")
+            ]) as inst:
+        ch = inst.channels[1]
+        ch.trigger_continuous = False
+        assert not ch.trigger_continuous
+        ch.trigger_continuous = True
+        assert ch.trigger_continuous
+
+
+def test_trigger_initiate():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ('INITiate1', None)
+            ]) as inst:
+        ch = inst.channels[1]
+        ch.trigger_initiate()
+
+
+def test_trigger_bus():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("*TRG", None)
+            ]) as inst:
+        inst.trigger_bus()
+
+
+def test_trigger():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("TRIGger", None)
+            ]) as inst:
+        inst.trigger()
+
+
+def test_trigger_single():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("TRIGger:SINGle", None)
+            ]) as inst:
+        inst.trigger_single()
+
+
+def test_wait_for_complete():
+    with expected_protocol(
+            AgilentE5062A,
+            [
+                *initial_comm_pairs(),
+                ("TRIGger:SINGle", None),
+                ("*OPC?", "1")
+            ]) as inst:
+        inst.trigger_single()
+        inst.wait_for_complete()
diff --git a/tests/instruments/agilent/test_agilentE5062A_with_device.py b/tests/instruments/agilent/test_agilentE5062A_with_device.py
new file mode 100644
index 0000000000..cce02a5558
--- /dev/null
+++ b/tests/instruments/agilent/test_agilentE5062A_with_device.py
@@ -0,0 +1,330 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2024 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+# Tested using SCPI over telnet (via ethernet). call signature:
+# $ pytest test_agilentE5062A_with_device.py --device-address 'TCPIP::192.168.2.233::INSTR'
+#
+# The RF stimulus is turned on with the default power level of 0 dBm. The test
+# port connection can be anything that is safe for that power level.
+
+import pytest
+from pymeasure.instruments.agilent.agilentE5062A import AgilentE5062A
+
+import numpy as np
+
+DISPLAY_LAYOUT_OPTIONS = [
+        "D1",
+        "D12",
+        "D1_2",
+        "D112",
+        "D1_1_2",
+        "D123",
+        "D1_2_3",
+        "D12_33",
+        "D11_23",
+        "D13_23",
+        "D12_13",
+        "D1234",
+        "D1_2_3_4",
+        "D12_34"
+]
+
+
+@pytest.fixture(scope="module")
+def agilentE5062A(connected_device_address):
+    instr = AgilentE5062A(connected_device_address)
+    # ensure the device is in a defined state, e.g. by resetting it.
+    return instr
+
+
+def test_ch_visible_traces(agilentE5062A):
+    agilentE5062A.clear()
+    # test ch visible_traces
+    ot4 = np.arange(4) + 1
+    for ch in agilentE5062A.channels.values():
+        for opt in ot4:
+            ch.visible_traces = opt
+            assert ch.visible_traces == opt
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_traces(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        ch.visible_traces = 4
+        for tr in ch.traces.values():
+            for p in ['S11', 'S12', 'S21', 'S22']:
+                tr.parameter = p
+                assert tr.parameter == p
+        tr.activate()
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_start_frequency(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for f in [3e5, 3e6, 3e7, 3e8, 3e9]:
+            ch.start_frequency = f
+            assert ch.start_frequency == f
+        ch.start_frequency = 3e5
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_stop_frequency(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for f in [3e5, 3e6, 3e7, 3e8, 3e9]:
+            ch.stop_frequency = f
+            assert ch.stop_frequency == f
+        ch.stop_frequency = 3e9
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_scan_points(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for p in [2, 201, 1601]:
+            ch.scan_points = p
+            assert ch.scan_points == p
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_sweep_time(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        ch.sweep_time_auto_enabled = False
+        assert not ch.sweep_time_auto_enabled
+        for t in [1, 2]:
+            ch.sweep_time = t
+            assert np.isclose(ch.sweep_time, t, rtol=.01)
+        ch.sweep_time_auto_enabled = True
+        assert ch.sweep_time_auto_enabled
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_sweep_type(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for t in ['LIN', 'LOG', 'SEGM', 'POW']:
+            ch.sweep_type = t
+            assert ch.sweep_type == t  # returns just the capitalized part
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_averaging(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        ch.averaging_enabled = True
+        assert ch.averaging_enabled
+        ch.restart_averaging()
+        for n in [1, 10, 999]:
+            ch.averages = n
+            assert ch.averages == n
+        ch.averaging_enabled = False
+        assert not ch.averaging_enabled
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_IF_bandwidth(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for bw in [10, 10e3, 30e3]:
+            ch.IF_bandwidth = bw
+            assert ch.IF_bandwidth == bw
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_display_layout(agilentE5062A):
+    agilentE5062A.clear()
+    for layout in DISPLAY_LAYOUT_OPTIONS:
+        agilentE5062A.display_layout = layout
+        assert agilentE5062A.display_layout == layout
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_attenuation(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        for atten in [0, 10, 20, 30, 40]:
+            ch.attenuation = atten
+            assert ch.attenuation == atten
+            for p in [-5, 0, 10]:
+                power = -atten + p
+                ch.power = power
+                assert ch.power == power
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_tr_display_layout(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.display_layout = 'D12_34'
+    for ch in agilentE5062A.channels.values():
+        for layout in DISPLAY_LAYOUT_OPTIONS:
+            ch.display_layout = layout
+            assert ch.display_layout == layout
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_activate_correct(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.display_layout = 'D1_2'
+    agilentE5062A.channels[2].activate()
+    agilentE5062A.display_layout = 'D1'
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_ch_activate_incorrect(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.display_layout = 'D1'
+    with pytest.raises(ValueError):
+        agilentE5062A.channels[2].activate()
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_tr_format(agilentE5062A):
+    agilentE5062A.clear()
+    ch = agilentE5062A.channels[1]
+    ch.activate()
+    ch.visible_traces = 4
+    for tr in ch.traces.values():
+        tr.activate()
+        for opt in ch.TRACE_FORMAT:
+            ch.trace_format = opt
+            assert ch.trace_format == opt
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_data(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.display_layout = 'D12_34'
+    for ch in agilentE5062A.channels.values():
+        ch.visible_traces = 1
+        tr = ch.traces[1]
+        tr.activate()
+        tr.trace_format = 'SCOM'
+        real, imag = ch.data
+        assert np.size(real) == ch.scan_points
+        assert np.size(imag) == ch.scan_points
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_frequencies(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.display_layout = 'D12_34'
+    for ch in agilentE5062A.channels.values():
+        ch.sweep_type = 'LIN'
+        freqs = ch.frequencies
+        assert np.size(freqs) == ch.scan_points
+        assert np.any(np.isclose(ch.start_frequency, freqs))
+        assert np.any(np.isclose(ch.stop_frequency, freqs))
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_reset(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.reset()
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_abort(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.abort()
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger_source(agilentE5062A):
+    agilentE5062A.clear()
+    for src in ['INT', 'EXT', 'MAN', 'BUS']:
+        agilentE5062A.trigger_source = src
+        assert agilentE5062A.trigger_source == src
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger_continuous(agilentE5062A):
+    agilentE5062A.clear()
+    for ch in agilentE5062A.channels.values():
+        ch.trigger_continuous = False
+        assert not ch.trigger_continuous
+        ch.trigger_continuous = True
+        assert ch.trigger_continuous
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger_initiate(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.reset()
+    for ch in agilentE5062A.channels.values():
+        ch.trigger_continuous = False
+        agilentE5062A.abort()  # the trigger needs to be in a hold state for initiate() to work.
+        ch.trigger_initiate()
+        ch.trigger_continuous = True
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger_bus(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.reset()
+    agilentE5062A.trigger_source = 'BUS'
+    for ch in agilentE5062A.channels.values():
+        ch.trigger_continuous = False
+        agilentE5062A.abort()   # the trigger needs to be in a hold state for initiate() to work.
+        ch.trigger_initiate()
+        agilentE5062A.trigger_bus()
+    agilentE5062A.trigger_source = 'INT'
+    agilentE5062A.trigger_continuous = True
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger(agilentE5062A):
+    agilentE5062A.clear()
+    agilentE5062A.reset()
+    agilentE5062A.trigger_source = 'BUS'
+    for ch in agilentE5062A.channels.values():
+        ch.trigger_continuous = False
+        agilentE5062A.abort()   # the trigger needs to be in a hold state for initiate() to work.
+        ch.trigger_initiate()
+        agilentE5062A.trigger()
+    agilentE5062A.trigger_source = 'INT'
+    agilentE5062A.trigger_continuous = True
+    assert not agilentE5062A.pop_err()[0]
+
+
+def test_trigger_single(agilentE5062A):
+    """trigger_single() is different from trigger() in that the VNA does not
+        return a response to *OPC? (the SCPI complete query) until the sweep is
+        complete.
+
+    """
+    agilentE5062A.clear()
+    agilentE5062A.reset()
+    agilentE5062A.trigger_source = 'BUS'
+    for ch in agilentE5062A.channels.values():
+        ch.trigger_continuous = False
+        agilentE5062A.abort()
+        ch.trigger_initiate()
+        agilentE5062A.trigger_single()
+        agilentE5062A.wait_for_complete()
+    agilentE5062A.trigger_source = 'INT'
+    agilentE5062A.trigger_continuous = True
+    assert not agilentE5062A.pop_err()[0]
diff --git a/tests/instruments/teledyne/test_teledyneMAUI.py b/tests/instruments/teledyne/test_teledyneMAUI.py
index 84f8cf702c..25ec52386e 100644
--- a/tests/instruments/teledyne/test_teledyneMAUI.py
+++ b/tests/instruments/teledyne/test_teledyneMAUI.py
@@ -117,5 +117,17 @@ def test_attenuation():
         assert instr.ch_1.probe_attenuation == 0.1
 
 
+def test_vbs():
+    with expected_protocol(
+        TeledyneMAUI,
+        [(b"CHDR OFF", None),
+         (b"VBS 'my_command(x)'", None),
+         (b"VBS? 'Return=my_var'", b"0\n"),
+         ]
+    ) as instr:
+        instr.vbs_write("my_command(x)")
+        assert instr.vbs_ask("my_var") == "0\n"
+
+
 if __name__ == '__main__':
     pytest.main()
diff --git a/tests/instruments/teledyne/test_teledyneMAUI_with_device.py b/tests/instruments/teledyne/test_teledyneMAUI_with_device.py
index 88305aeb94..c2c3a37e24 100644
--- a/tests/instruments/teledyne/test_teledyneMAUI_with_device.py
+++ b/tests/instruments/teledyne/test_teledyneMAUI_with_device.py
@@ -309,6 +309,12 @@ def test_trigger_setup(self, resetted_instrument):
         resetted_instrument.trigger_setup(**expected)
         assert resetted_instrument.trigger == expected
 
+    def test_vbs(self, resetted_instrument):
+        """Test reading and writing values through the VBS interface."""
+        resetted_instrument.vbs_write("app.Acquisition.C1.VerScale=0.01")
+        val = resetted_instrument.vbs_ask("app.Acquisition.C1.VerScale")
+        assert val == "0.01\n"
+
 
 if __name__ == "__main__":
     pytest.main()