Repository: pymeasure/pymeasure

Issue:
Add support for the Teledyne LeCroy HDO6xxx oscilloscope
I would like support for the [LeCroy HDO6054B oscilloscope](https://teledynelecroy.com/oscilloscope/oscilloscopemodel.aspx?modelid=11553). I already have an old private package that controls it, so I hope to be able to port this myself.

Note that that the product is called "Teledyne Lecroy", and I see `pymeasure` has both instrument folders for Teledyne and Lecroy, so I'm not sure where to put it. Any suggestions?

And I hope I can reuse some of the logic from https://pymeasure.readthedocs.io/en/latest/api/instruments/lecroy/lecroyT3DSO1204.html.

I expect that the HDO6xxx family can be controlled the same way, but I am not sure and I cannot test them all. How should I name and describe the device in `pymeasure`?

Connection is made using NI Visa.


Discussion:
 Actually, the interface for the T3DSO1204 might be identical. I cannot find a different programming interface for the HDO6054B, and my existing code uses mostly the same commands (but the long spelling instead of the short one).
Welcome!
> Note that that the product is called "Teledyne Lecroy", and I see pymeasure has both instrument folders for Teledyne and Lecroy, so I'm not sure where to put it. Any suggestions?

This happens regularly, with instrument manufacturers going through mergers/renames/acquisitions etc. We normally go with what it says on the  nameplate, figuring that is what users will look for first. So, if your device front says "Teledyne Lecroy", you can add another manufacturer tree `teledynelecroy` -- I think this should now cover all bases :sweat_smile:.
 
It's very good to reuse logic, as this avoids code duplication -- please see the docs here: https://pymeasure.readthedocs.io/en/latest/dev/adding_instruments/properties.html#instruments-with-similar-features
First, you could check if you can operate your device with the T3DSO1204 class as expected -- this could confirm that is can deal with the short spelling too, that would be ideal. 
If not, you can try the other way round and compare with the T3DSO1204, if that can deal with the long spelling. (Those long/short names_should_ be interchangeable, but not all devices actually work like that).

> I expect that the HDO6xxx family can be controlled the same way, but I am not sure and I cannot test them all. How should I name and describe the device in pymeasure?

You can check in the respective manuals to see what the differences are -- if the main differences are parameter ranges or some missing functionality, it can be easy to split out at least a base class for common functionality `HDO6xxx` or `HDO6000`, and derive/extend your specific model class `HDO6054B` from that. See [here](https://pymeasure.readthedocs.io/en/latest/dev/adding_instruments/properties.html#instrument-family-with-different-parameter-values). 
If you end up mostly testing the "main" family class code with your instrument, too, that would be enough for us. 

If you have further questions, we're here to assist. :-)

Regarding the name:
If it says Teledyne Lecroy, I'd put it into Teledyne, because people tend to start to compare at the beginning.
Nevertheless, both manufacturer's page says, that there is also the other page to look for instruments, so either way is good.

Resolving patch:
diff --git a/pymeasure/instruments/teledyne/__init__.py b/pymeasure/instruments/teledyne/__init__.py
--- a/pymeasure/instruments/teledyne/__init__.py
+++ b/pymeasure/instruments/teledyne/__init__.py
@@ -24,3 +24,4 @@
 
 from .teledyneT3AFG import TeledyneT3AFG
 from .teledyne_oscilloscope import TeledyneOscilloscope
+from .teledyneMAUI import TeledyneMAUI
diff --git a/pymeasure/instruments/teledyne/teledyneMAUI.py b/pymeasure/instruments/teledyne/teledyneMAUI.py
new file mode 100644
--- /dev/null
+++ b/pymeasure/instruments/teledyne/teledyneMAUI.py
@@ -0,0 +1,213 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 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
+from pymeasure.instruments.teledyne.teledyne_oscilloscope import TeledyneOscilloscope, \
+    TeledyneOscilloscopeChannel, _results_list_to_dict
+
+
+class TeledyneMAUIChannel(TeledyneOscilloscopeChannel):
+    """Base class for channels on a :class:`TeledyneMAUI` device."""
+
+    # Probably for historic reasons, "20MHZ" is registered as "ON"
+    BANDWIDTH_LIMITS = ["OFF", "ON", "200MHZ"]
+
+    TRIGGER_SLOPES = {"negative": "NEG", "positive": "POS"}
+
+    # Reset listed values for existing commands:
+    bwlimit_values = BANDWIDTH_LIMITS
+
+    def autoscale(self):
+        """Perform auto-setup command for channel."""
+        self.write("AUTO_SETUP FIND")
+
+    # noinspection PyIncorrectDocstring
+    def setup(self, **kwargs):
+        """Setup channel. Unspecified settings are not modified.
+
+        Modifying values such as probe attenuation will modify offset, range, etc. Refer to
+        oscilloscope documentation and make multiple consecutive calls to setup() if needed.
+        See property descriptions for more information.
+
+        :param bwlimit:
+        :param coupling:
+        :param display:
+        :param offset:
+        :param probe_attenuation:
+        :param scale:
+        :param trigger_coupling:
+        :param trigger_level:
+        :param trigger_slope:
+        """
+        super().setup(**kwargs)
+
+    @property
+    def current_configuration(self):
+        """Get channel configuration as a dict containing the following keys:
+
+        - "channel": channel number (int)
+        - "attenuation": probe attenuation (float)
+        - "bandwidth_limit": bandwidth limiting, parsed for this channel (str)
+        - "coupling": "ac 1M", "dc 1M", "ground" coupling (str)
+        - "offset": vertical offset (float)
+        - "display": currently displayed (bool)
+        - "volts_div": vertical divisions (float)
+        - "trigger_coupling": trigger coupling can be "dc" "ac" "highpass" "lowpass" (str)
+        - "trigger_level": trigger level (float)
+        - "trigger_slope": trigger slope can be "negative" "positive" "window" (str)
+        """
+        ch_setup = {
+            "channel": self.id,
+            "attenuation": self.probe_attenuation,
+            "bandwidth_limit": self.bwlimit[f"C{self.id}"],
+            "coupling": self.coupling,
+            "offset": self.offset,
+            "display": self.display,
+            "volts_div": self.scale,
+            "trigger_coupling": self.trigger_coupling,
+            "trigger_level": self.trigger_level,
+            "trigger_slope": self.trigger_slope
+        }
+        return ch_setup
+
+
+class TeledyneMAUI(TeledyneOscilloscope):
+    """A base class for the MAUI-type of Teledyne oscilloscopes.
+
+    This base class works out of the box. Some properties, especially the number of channels,
+    might have to be adjusted to the actual device.
+
+    The manual detailing the API is "MAUI Oscilloscopes Remote Control and Automation Manual"
+    (`link`_).
+
+    .. _link: https://cdn.teledynelecroy.com/files/manuals/
+              maui-remote-control-automation_27jul22.pdf
+    """
+
+    ch_1 = Instrument.ChannelCreator(TeledyneMAUIChannel, 1)
+
+    ch_2 = Instrument.ChannelCreator(TeledyneMAUIChannel, 2)
+
+    ch_3 = Instrument.ChannelCreator(TeledyneMAUIChannel, 3)
+
+    ch_4 = Instrument.ChannelCreator(TeledyneMAUIChannel, 4)
+
+    # Change listed values for existing commands:
+    bwlimit_values = TeledyneMAUIChannel.BANDWIDTH_LIMITS
+
+    ###############
+    #   Trigger   #
+    ###############
+
+    @property
+    def trigger(self):
+        """Get trigger setup as a dict containing the following keys:
+
+        - "mode": trigger sweep mode [auto, normal, single, stop]
+        - "trigger_type": condition that will trigger the acquisition of waveforms
+          [edge,slew,glit,intv,runt,drop]
+        - "source": trigger source [c1,c2,c3,c4]
+        - "hold_type": hold type (refer to page 172 of programing guide)
+        - "hold_value1": hold value1 (refer to page 172 of programing guide)
+        - "hold_value2": hold value2 (refer to page 172 of programing guide)
+        - "coupling": input coupling for the selected trigger sources
+        - "level": trigger level voltage for the active trigger source
+        - "slope": trigger slope of the specified trigger source
+
+        """
+        trigger_select = self.trigger_select
+        ch = self.ch(trigger_select[1])
+        tb_setup = {
+            "mode": self.trigger_mode,
+            "trigger_type": trigger_select[0],
+            "source": trigger_select[1],
+            "hold_type": trigger_select[2],
+            "hold_value1": trigger_select[3] if len(trigger_select) >= 4 else None,
+            "hold_value2": trigger_select[4] if len(trigger_select) >= 5 else None,
+            "coupling": ch.trigger_coupling,
+            "level": ch.trigger_level,
+            "slope": ch.trigger_slope
+        }
+        return tb_setup
+
+    def force_trigger(self):
+        """Make one acquisition if in active trigger mode.
+
+        No action is taken if the device is in 'Stop trigger mode'.
+        """
+        # Method instead of property since no reply is sent
+        self.write("FRTR")
+
+    #################
+    # Download data #
+    #################
+
+    hardcopy_setup_current = Instrument.measurement(
+        "HCSU?",
+        """Get current hardcopy config.""",
+        get_process=_results_list_to_dict,
+    )
+
+    def hardcopy_setup(self, **kwargs):
+        """Specify hardcopy settings.
+
+        Connect a printer or define how to save to file. Set any or all
+        of the following parameters.
+
+        :param device: {BMP, JPEG, PNG, TIFF}
+        :param format: {PORTRAIT, LANDSCAPE}
+        :param background: {Std, Print, BW}
+        :param destination: {PRINTER, CLIPBOARD, EMAIL, FILE, REMOTE}
+        :param area: {GRIDAREAONLY, DSOWINDOW, FULLSCREEN}
+        :param directory: Any legal DOS path, for FILE mode only
+        :param filename: Filename string, no extension, for FILE mode only
+        :param printername: Valid printer name, for PRINTER mode only
+        :param portname: {GPIB, NET}
+        """
+        keys = {
+            "device": "DEV",
+            "format": "FORMAT",
+            "background": "BCKG",
+            "destination": "DEST",
+            "area": "AREA",
+            "directory": "DIR",
+            "filename": "FILE",
+            "printername": "PRINTER",
+            "portname": "PORT",
+        }
+
+        arg_strs = [keys[key] + "," + value for key, value in kwargs.items()]
+
+        self.write("HCSU " + ",".join(arg_strs))
+
+    def download_image(self, **kwargs):
+        """Get a BMP image of oscilloscope screen in bytearray of specified file format.
+
+        The hardcopy destination is set to "REMOTE" by default.
+
+        :param \\**kwargs: Keyword arguments for :meth:`hardcopy_setup`
+        """
+        kwargs.setdefault("destination", "REMOTE")
+        self.hardcopy_setup(**kwargs)
+        return super().download_image()
diff --git a/pymeasure/instruments/teledyne/teledyneT3AFG.py b/pymeasure/instruments/teledyne/teledyneT3AFG.py
--- a/pymeasure/instruments/teledyne/teledyneT3AFG.py
+++ b/pymeasure/instruments/teledyne/teledyneT3AFG.py
@@ -126,11 +126,12 @@ class TeledyneT3AFG(Instrument):
     """Represents the Teledyne T3AFG series of arbitrary waveform
     generator interface for interacting with the instrument.
 
-    Intially targeting T3AFG80, some features may not be available on
+    Initially targeting T3AFG80, some features may not be available on
     lower end models and features from higher end models are not
-    included here intially.
+    included here yet.
 
     Future improvements (help welcomed):
+
     - Add other OUTPut related controls like Load and Polarity
     - Add other Basic Waveform related controls like Period
     - Add frequency ranges per model


Test patch:
diff --git a/tests/instruments/teledyne/test_teledyneMAUI.py b/tests/instruments/teledyne/test_teledyneMAUI.py
new file mode 100644
--- /dev/null
+++ b/tests/instruments/teledyne/test_teledyneMAUI.py
@@ -0,0 +1,121 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2023 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.instruments.teledyne.teledyne_oscilloscope import sanitize_source
+from pymeasure.instruments.teledyne.teledyneMAUI import TeledyneMAUI
+from pymeasure.test import expected_protocol
+
+INVALID_CHANNELS = ["INVALID_SOURCE", "C1 C2", "C1 MATH", "C1234567", "CHANNEL"]
+VALID_CHANNELS = [('C1', 'C1'), ('CHANNEL2', 'C2'), ('ch 3', 'C3'), ('chan 4', 'C4'),
+                  ('\tC3\t', 'C3'), (' math ', 'MATH')]
+
+
+def test_init():
+    with expected_protocol(
+            TeledyneMAUI,
+            [(b"CHDR OFF", None)]
+    ):
+        pass  # Verify the expected communication.
+
+
+def test_removed_property():
+    """Verify that certain inherited properties are removed successfully.
+
+    Some actions from the base class are not valid for this one.
+    """
+    with expected_protocol(TeledyneMAUI, [(b'CHDR OFF', None)]) as instr:
+        props = ["timebase_hor_magnify"]
+        for prop in props:
+            with pytest.raises(AttributeError):
+                _ = getattr(instr, prop)
+
+        ch_props = ["trigger_level2", "skew_factor", "unit", "invert"]
+        for prop in ch_props:
+            with pytest.raises(AttributeError):
+                _ = getattr(instr.ch(1), prop)
+
+
+@pytest.mark.parametrize("channel", INVALID_CHANNELS)
+def test_invalid_source(channel):
+    with pytest.raises(ValueError):
+        sanitize_source(channel)
+
+
+@pytest.mark.parametrize("channel", VALID_CHANNELS)
+def test_sanitize_valid_source(channel):
+    assert sanitize_source(channel[0]) == channel[1]
+
+
+def test_bwlimit():
+    with expected_protocol(
+            TeledyneMAUI,
+            [(b"CHDR OFF", None),
+             (b"BWL C1,OFF", None),
+             (b"BWL?", b"C1,OFF"),
+             (b"BWL C1,200MHZ", None),
+             (b"BWL?", b"C1,200MHZ"),
+             (b"BWL C1,ON", None),
+             (b"BWL?", b"C1,ON"),
+             ]
+    ) as instr:
+        instr.ch_1.bwlimit = "OFF"
+        assert instr.bwlimit["C1"] == "OFF"
+        instr.ch_1.bwlimit = "200MHZ"
+        assert instr.bwlimit["C1"] == "200MHZ"
+        instr.ch_1.bwlimit = "ON"
+        assert instr.bwlimit["C1"] == "ON"
+
+
+def test_offset():
+    with expected_protocol(
+            TeledyneMAUI,
+            [(b"CHDR OFF", None),
+             (b"C1:OFST 1.00E+00V", None),
+             (b"C1:OFST?", b"1.00E+00")
+             ]
+    ) as instr:
+        instr.ch_1.offset = 1.
+        assert instr.ch_1.offset == 1.
+
+
+def test_attenuation():
+    with expected_protocol(
+            TeledyneMAUI,
+            [(b"CHDR OFF", None),
+             (b"C1:ATTN 100", None),
+             (b"C1:ATTN?", b"100"),
+             (b"C1:ATTN 0.1", None),
+             (b"C1:ATTN?", b"0.1")
+             ]
+    ) as instr:
+        instr.ch_1.probe_attenuation = 100
+        assert instr.ch_1.probe_attenuation == 100
+        instr.ch_1.probe_attenuation = 0.1
+        assert instr.ch_1.probe_attenuation == 0.1
+
+
+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
new file mode 100644
--- /dev/null
+++ b/tests/instruments/teledyne/test_teledyneMAUI_with_device.py
@@ -0,0 +1,314 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2023 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 time import sleep
+
+from pymeasure.instruments.teledyne import TeledyneMAUI
+
+
+class TestTeledyneMAUI:
+    """
+    Unit tests for TeledyneMAUI class.
+
+    This test suite needs an actual device connected, compatible with the MAUI interface.
+    Use the ``--device-address`` flag for pytest to define your device.
+    """
+
+    #########################
+    # PARAMETRIZATION CASES #
+    #########################
+
+    BOOLEANS = [False, True]
+    BANDWIDTH_LIMITS = ["OFF", "ON", "200MHZ"]
+    CHANNEL_COUPLINGS = ["ac 1M", "dc 1M", "ground"]
+    ACQUISITION_TYPES = ["normal", "average", "peak", "highres"]
+    TRIGGER_LEVELS = [0.125, 0.150, 0.175]
+    TRIGGER_SLOPES = ["negative", "positive"]
+    ACQUISITION_AVERAGE = [4, 16, 32, 64, 128, 256]
+    WAVEFORM_POINTS = [100, 1000, 10000]
+    WAVEFORM_SOURCES = ["C1", "C2", "C3", "C4"]
+    CHANNELS = [1, 2, 3, 4]
+
+    ############
+    # FIXTURES #
+    ############
+
+    @pytest.fixture(scope="module")
+    def instrument(self, connected_device_address):
+        return TeledyneMAUI(connected_device_address)
+
+    @pytest.fixture
+    def resetted_instrument(self, instrument):
+        instrument.reset()
+        sleep(7)
+        return instrument
+
+    @pytest.fixture
+    def autoscaled_instrument(self, instrument):
+        instrument.reset()
+        sleep(7)
+        instrument.autoscale()
+        sleep(7)
+        return instrument
+
+    #########
+    # TESTS #
+    #########
+
+    def test_instrument_connection(self, connected_device_address):
+        instrument = TeledyneMAUI(connected_device_address)
+        channel = instrument.ch(1)
+        assert channel is not None
+
+    def test_channel_autoset(self, instrument):
+        instrument.ch(1).autoscale()
+        sleep(0.1)
+
+    def test_channel_measure_parameter(self, instrument):
+        instrument.ch(1).measure_parameter("RMS")
+        sleep(0.1)
+
+    # Channel
+    def test_ch_current_configuration(self, autoscaled_instrument):
+        autoscaled_instrument.ch_1.offset = 0
+        autoscaled_instrument.ch_1.trigger_level = 0
+        expected = {
+            "channel": 1,
+            "attenuation": 1.0,
+            "bandwidth_limit": "OFF",
+            "coupling": "dc 1M",
+            "offset": 0.0,
+            "display": True,
+            "volts_div": 0.05,
+            "trigger_coupling": "dc",
+            "trigger_level": 0.0,
+            "trigger_slope": "positive",
+        }
+        actual = autoscaled_instrument.ch(1).current_configuration
+        assert actual == expected
+
+    @pytest.mark.parametrize("case", BANDWIDTH_LIMITS)
+    def test_ch_bwlimit(self, instrument, case):
+        instrument.bwlimit = case
+        expected = {ch: case for ch in self.WAVEFORM_SOURCES}
+        assert instrument.bwlimit == expected
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    @pytest.mark.parametrize("case", BANDWIDTH_LIMITS)
+    def test_ch_bwlimit_channel(self, instrument, ch_number, case):
+        instrument.ch(ch_number).bwlimit = case
+        assert instrument.bwlimit[f"C{ch_number}"] == case
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    @pytest.mark.parametrize("case", CHANNEL_COUPLINGS)
+    def test_ch_coupling(self, instrument, ch_number, case):
+        instrument.ch(ch_number).coupling = case
+        assert instrument.ch(ch_number).coupling == case
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    @pytest.mark.parametrize("case", BOOLEANS)
+    def test_ch_display(self, instrument, ch_number, case):
+        instrument.ch(ch_number).display = case
+        assert instrument.ch(ch_number).display == case
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    def test_ch_offset(self, instrument, ch_number):
+        instrument.ch(ch_number).offset = 1
+        assert instrument.ch(ch_number).offset == 1
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    def test_ch_probe_attenuation(self, instrument, ch_number):
+        instrument.ch(ch_number).probe_attenuation = 10
+        assert instrument.ch(ch_number).probe_attenuation == 10
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    def test_ch_scale(self, instrument, ch_number):
+        instrument.ch(ch_number).scale = 1
+        assert instrument.ch(ch_number).scale == 1
+
+    def test_ch_trigger_level(self, autoscaled_instrument):
+        for case in self.TRIGGER_LEVELS:
+            autoscaled_instrument.ch_1.trigger_level = case
+            assert autoscaled_instrument.ch_1.trigger_level == case
+
+    def test_ch_trigger_slope(self, autoscaled_instrument):
+        with pytest.raises(ValueError):
+            autoscaled_instrument.ch_1.trigger_slope = "abcd"
+        autoscaled_instrument.trigger_select = ("edge", "c1", "off")
+        for case in self.TRIGGER_SLOPES:
+            autoscaled_instrument.ch_1.trigger_slope = case
+            assert autoscaled_instrument.ch_1.trigger_slope == case
+
+    # Timebase
+
+    def test_timebase(self, autoscaled_instrument):
+        autoscaled_instrument.timebase_scale = 5e-4
+        autoscaled_instrument.timebase_offset = 0
+        expected = {
+            "timebase_scale": 5e-4,
+            "timebase_offset": 0.0,
+        }
+        actual = autoscaled_instrument.timebase
+        for key, val in actual.items():
+            assert pytest.approx(val, 0.1) == expected[key]
+
+    def test_timebase_scale(self, resetted_instrument):
+        resetted_instrument.timebase_scale = 1e-3
+        assert resetted_instrument.timebase_scale == 1e-3
+
+    def test_timebase_offset(self, instrument):
+        instrument.timebase_offset = 1e-3
+        assert instrument.timebase_offset == 1e-3
+
+    # Acquisition
+
+    @pytest.mark.parametrize("case", WAVEFORM_POINTS)
+    def test_waveform_points(self, instrument, case):
+        instrument.waveform_points = case
+        assert instrument.waveform_points == case
+
+    def test_waveform_preamble(self, autoscaled_instrument):
+        autoscaled_instrument.ch_1.offset = 0
+        autoscaled_instrument.waveform_points = 0
+        autoscaled_instrument.waveform_first_point = 0
+        autoscaled_instrument.waveform_sparsing = 1
+        autoscaled_instrument.waveform_source = "C1"
+        expected_preamble = {
+            "sparsing": 1.0,
+            "requested_points": 0.0,
+            "memory_size": 2.5e6,
+            "transmitted_points": None,
+            "first_point": 0.0,
+            "source": "C1",
+            "grid_number": 14,
+            "xdiv": 1e-6,
+            "xoffset": 0.0,
+            "ydiv": 0.05,
+            "yoffset": 0.0,
+        }
+        preamble = autoscaled_instrument.waveform_preamble
+        assert preamble == expected_preamble
+
+    # Setup methods
+
+    @pytest.mark.parametrize("ch_number", CHANNELS)
+    def test_channel_setup(self, instrument, ch_number):
+        # Only autoscale on the first channel
+        instrument = instrument
+        if ch_number == self.CHANNELS[0]:
+            instrument.reset()
+            sleep(7)
+            instrument.autoscale()
+            sleep(7)
+
+        # Not testing the actual values assignment since different combinations of
+        # parameters can play off each other.
+        expected = instrument.ch(ch_number).current_configuration
+        instrument.ch(ch_number).setup()
+        assert instrument.ch(ch_number).current_configuration == expected
+        with pytest.raises(AttributeError):
+            instrument.ch(5)
+        instrument.ch(ch_number).setup(
+            bwlimit="ON",
+            coupling="dc 1M",
+            display=True,
+            offset=0.0,
+            probe_attenuation=1.0,
+            scale=0.05,
+            trigger_coupling="dc",
+            trigger_level=0.150,
+            trigger_slope="positive",
+        )
+        expected = {
+            "channel": ch_number,
+            "attenuation": 1.0,
+            "bandwidth_limit": "ON",
+            "coupling": "dc 1M",
+            "offset": 0.0,
+            "display": True,
+            "volts_div": 0.05,
+            "trigger_coupling": "dc",
+            "trigger_level": 0.150,
+            "trigger_slope": "positive",
+        }
+        actual = instrument.ch(ch_number).current_configuration
+        assert actual == expected
+
+    def test_timebase_setup(self, resetted_instrument):
+        expected = resetted_instrument.timebase
+        resetted_instrument.timebase_setup()
+        assert resetted_instrument.timebase == expected
+
+    # Download methods
+
+    def test_hardcopy_setup(self, instrument):
+
+        instrument.hardcopy_setup(
+            device="BMP", format="PORTRAIT", background="Std", destination="FILE",
+            area="GRIDONLY", directory="D:\\Waveforms\\temp"
+        )
+        config_expected = {
+            "DEV": "BMP", "FORMAT": "PORTRAIT", "BCKG": "Std", "DEST": "FILE",
+            "DIR": '"D:\\WAVEFORMS\\TEMP"', "AREA": "GRIDAREAONLY",
+        }
+        config = instrument.hardcopy_setup_current
+        assert config_expected.items() <= config.items()
+
+    def test_download_image_default_arguments(self, autoscaled_instrument):
+        """Note: the path specified here must exist on the device already!"""
+        img = autoscaled_instrument.download_image()
+        assert type(img) is bytearray
+        assert pytest.approx(len(img), 0.1) == 2734135
+
+    # Trigger
+
+    def test_trigger_select(self, resetted_instrument):
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = "edge"
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = ("edge", "c2")
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = ("edge", "c2", "time")
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = ("ABCD", "c1", "time", 0)
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = ("edge", "c1", "time", 1000)
+        with pytest.raises(ValueError):
+            resetted_instrument.trigger_select = ("edge", "c1", "time", 0, 1)
+        resetted_instrument.trigger_select = ("edge", "c1", "off")
+        resetted_instrument.trigger_select = ("EDGE", "C1", "OFF")
+        assert resetted_instrument.trigger_select == ["edge", "c1", "off"]
+        resetted_instrument.trigger_select = ("glit", "c1", "p2", 1e-3, 2e-3)
+        assert resetted_instrument.trigger_select == ["glit", "c1", "p2", 1e-3, 2e-3]
+
+    def test_trigger_setup(self, resetted_instrument):
+        expected = resetted_instrument.trigger
+        resetted_instrument.trigger_setup(**expected)
+        assert resetted_instrument.trigger == expected
+
+
+if __name__ == "__main__":
+    pytest.main()