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:
RobertoRoos: I have worked on an alternative.
The HDO6 class needs some properties to be removed, which is a bit of a pain. So I suggest to go for the more conventional approach and relying more on inheritance.

I haven't pushed new commits into this PR yet, instead I created a new one and created a PR on my own fork just to display the suggested changes:
https://github.com/DEMCON/pymeasure/pull/2/files

The idea is to build on four classes (for now):
![UML](https://cdn-0.plantuml.com/plantuml/png/SoWkIImgAStDuGf9pKbDgSnBzIzEpip9oIzEpYz8LR2fqTLLy4brBigl335s2VOtD38muSAZ52RZwnZgwPoHc9UNciE5HGnLiOUBlrb5HGMNGsfU2j0b0000)

I'm fairly sure this [MAUI-group](https://teledynelecroy.com/maui/) of devices are very similar, hence I would add it as an intermediate class.

All logic would be moved upwards as much as possible.

Any opinions?
bmoneke: Just a quick note: For us maintainers, protocol tests are more useful than tests with an actual device as we can determine whether a code change breaks an implementation without having the device at all. 
bilderbuchi: The approach of layering classes sounds reasonable. However, what seems weird to me is that a "Channel" inherits from a full "oscilloscope"
bilderbuchi: > But I was also checking the manual for the T3DSO (https://cdn.teledynelecroy.com/files/manuals/tds031000-2000_programming_manual.pdf) and noticed that some differences in the current code are not supported by the manual.
> For example, according to both PDFs the query `BANDWIDTH_LIMIT?` should for both devices return the BWL for all channels, e.g. "CH1,OFF,CH2,ON,CH3,ON,CH4,OFF". But the tests for `LeCroyT3DSO1204` expect a reply per channel.

Maybe the original contributor @LastStarDust can help out clearing this up?
LastStarDust: > > But I was also checking the manual for the T3DSO (https://cdn.teledynelecroy.com/files/manuals/tds031000-2000_programming_manual.pdf) and noticed that some differences in the current code are not supported by the manual.
> > For example, according to both PDFs the query `BANDWIDTH_LIMIT?` should for both devices return the BWL for all channels, e.g. "CH1,OFF,CH2,ON,CH3,ON,CH4,OFF". But the tests for `LeCroyT3DSO1204` expect a reply per channel.
> 
> Maybe the original contributor @LastStarDust can help out clearing this up?

I will check it as soon as I am in front of the hardware. I would not be too surprised if the hardware behavior is not consistent with the manual. It has happened before.
RobertoRoos: > The approach of layering classes sounds reasonable (and is great for code reuse!). However, what seems weird to me is that a "Channel" inherits from a full "oscilloscope"

Whoops, that was just a typo. The channel will actually have their own inheritance, mirroring the device classes.
The duplicate structure is not ideal, but there seems no way around it.

> you should be able to delete properties after class definition -- del ScopeChannel.trigger_level2.
Although, ideally such properties are pushed down to the class that adds them (i.e. the commonalities are pulled up into a common superclass), as removing class behaviour is considered "bad practice".

Of course, this was just a remnant of the old concept with limited inheritance. I have it set-up as you describe now.
RobertoRoos: Looks like you're on board with the hierarchical structure, so I pulled in my new branch.
bmoneke: I had a quick glance at the code and that is promising.

Do I understand it correctly, that you moved the code from the TS... oscilloscope to the general teledyne_oscilloscope?
LastStarDust: > Okay, I think I'm about done. Some pretty big refactors already.
> 
> The new HDO6 is not nearly as far implemented as the T3DSO is, but this is as much as I need. (And I don't have experience with oscilloscopes to begin with).
> At least there should be no functions left in the HDO6 class that cause errors.
> 
> @LastStarDust do you think you could run the device tests of this branch on the T3DSO again? 

Yes, I can run the tests but it might take about a week before I can get hold of the hardware.
bilderbuchi: The test failure comes from the code example in the `TeledyneT3AFG` constructor docstring I think. Try to make it look like this:
``` 
    - Add frequency ranges per model
    - Add channel coupling control

    .. code-block: python
        # Example assumes Ethernet (TCPIP) interface
        generator=TeledyneT3AFG('TCPIP0::xxx.xxx.xxx.xxx::pppp::SOCKET')
        generator.reset()
        generator.ch_1.wavetype='SINE'
        generator.ch_1.amplitude=2
        generator.ch_1.output_enabled=True
        
    """
```
bilderbuchi: @LastStarDust did you manage to run the tests on the hardware that you intended? Was the possible discrepancy with the manual mentioned [above](https://github.com/pymeasure/pymeasure/pull/868#issuecomment-1455069910) clarified?
ashokbruno: Sorry I am late to the conversation. I worked at Teledyne LeCroy for a long long time, and I am pretty familiar with the remote operation of the scope. I am onboard with the architecture proposed by @RobertoRoos. 
Here's my comments/responses:
The subsidary that makes scope is Teledyne LeCroy (a small part of Teledyne), Any T3xxx products are under the umbrella Teledyne Test Services.  They are not MAUI based and while it may support SCPI type commands, it may not support the VBS style commands which are the automation framework for LeCroy scopes. Also the automation of these devices are more robust using this framework compared to the SCPI type commands. 
I would seperate the implementation of T3 class based products from the MAUI based products. While I was working at LeCroy I had started a repo to create a high level class most people would be able to use with their scopes. It is by no means complete but it has a fair amount of functionality and code. See  https://github.com/TeledyneLeCroy/lecroydso and https://lecroydso.readthedocs.org/

I need a LeCroy DSO for the Labmaster series of Oscilloscopes which is also a MAUI based high end oscilloscope, and one of my colleagues was looking into integrating it with pymeasure. I will look at your implementation in closer detail to see how we can contribute to this.

Also if you want to test this on different scope models, Teledyne LeCroy offers a product called MAUI Studio(https://www.teledynelecroy.com/support/softwaredownload/mauistudio.aspx) which allows the scope to run on your PC, and simulate various things including remote operation.
bilderbuchi: @ashokbruno thanks for the additional insight, that's interesting and useful!

Patch:
diff --git a/docs/api/instruments/teledyne/index.rst b/docs/api/instruments/teledyne/index.rst
index 220fda49..7358c5cb 100644
--- a/docs/api/instruments/teledyne/index.rst
+++ b/docs/api/instruments/teledyne/index.rst
@@ -15,7 +15,13 @@ If the instrument you are looking for is not here, also check :doc:`LeCroy<../le
 
 
 There are shared base classes for Teledyne oscilloscopes.
-If your device is missing, the base class might already work well enough.
+These base classes already work directly for some devices, the following are confirmed:
+
+* :class:`~pymeasure.instruments.teledyne.TeledyneMAUI`
+
+  * Teledyne LeCroy HDO6xxx series (e.g. HDO6054B)
+
+If your device is not listed, the base class might already work well enough anyway.
 If adding a new device, these base classes should limit the amount of new code necessary.
 
 .. toctree::
diff --git a/docs/api/instruments/teledyne/teledyne_bases.rst b/docs/api/instruments/teledyne/teledyne_bases.rst
index 6b6283f1..904b2609 100644
--- a/docs/api/instruments/teledyne/teledyne_bases.rst
+++ b/docs/api/instruments/teledyne/teledyne_bases.rst
@@ -15,3 +15,17 @@ Teledyne Channel
 .. autoclass:: pymeasure.instruments.teledyne.teledyne_oscilloscope.TeledyneOscilloscopeChannel
    :members:
    :show-inheritance:
+
+Teledyne MAUI Oscilloscope
+##########################
+
+.. autoclass:: pymeasure.instruments.teledyne.TeledyneMAUI
+   :members:
+   :show-inheritance:
+
+Teledyne MAUI Channel
+#####################
+
+.. autoclass:: pymeasure.instruments.teledyne.teledyneMAUI.TeledyneMAUIChannel
+   :members:
+   :show-inheritance:
diff --git a/pymeasure/instruments/teledyne/__init__.py b/pymeasure/instruments/teledyne/__init__.py
index 036803df..71e82b91 100644
--- 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
index 00000000..1160c465
--- /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
index 69212502..f38cd39f 100644
--- 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
index 00000000..2ba5e6f1
--- /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
index 00000000..1d046025
--- /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()