Repository: QuTech-Delft/QMI (language: python)

Issue:
Adding QMI Drivers for Tenma power supplies
Description 
=========== 
Add QMI drivers for the Tenma benchtop power supply models 72-13360 & 72-2550 in QMI. QMI instrument drivers, already working but rough versions can be found here [13360](https://gitlab.tudelft.nl/team-diamond/diamondos/-/blob/56fc5ca1d0edea3998d995998bd07667df7598e9/instruments/tenma/tenma72_13360.py) and [2550](https://gitlab.tudelft.nl/team-diamond/diamondos/-/blob/56fc5ca1d0edea3998d995998bd07667df7598e9/instruments/tenma/tenma72_2550.py). These are expecting for serial transport strings, but should be edited such that also other transport strings, especially (TBD in [here](https://github.com/orgs/QuTech-Delft/projects/2/views/1?pane=issue&itemId=47294829)) UDP transport can also be used.

There exists already a nice package for controlling Tenma power supplies via serial:
https://github.com/kxtells/tenma-serial/blob/master/tenma/tenmaDcLib.py#L798
From this we could adopt the idea of creating a base class `Tenma_72Base` for our two drivers. In the base class would be defined some class attributes like the max Volt and Amp. We can probably ignore the `NCONFS` as at the moment there is no requirement to save/load configurations. We can leave the default baudrate attribute for serial implementations. Probably (but not checked) some `rpc_method`s could be implemented already in the base class, like `open()`, `close()` and `get_idn()`. For the rest check per method if the implementation can be in the base class. If not, line out the method with inputs, outputs and a docstring, but simply return `NotImplementedError`. The implementations will then be in the "concrete" classes.

Go through the example drivers' code and the pypi code and implement the necessary functions for classes `Tenma_722550` and `Tenma_7213360` in `qmi.instruments.tenma.psu_72` module. Also remember to include description and imports in the `__init__.py` so that the QMI driver show nice description in the Sphinx-generated documentation as well (see other QMI drivers for examples). 

You can also add nice [CLI tools for testing](https://github.com/QuTech-Delft/QMI/tree/main/bin/instruments).

Affected components 
------------------- 
QMI

Modules to be created 
--------------------- 
`qmi/instruments/tenma/psu_72.py`

Modules to be modified
---------------------- 
N/A

Tests to be created/updated 
--------------------------- 
`tests/instruments/tenma/test_psu_72.py`

Documentation to be updated 
--------------------------- 
`CHANGELOG.md`

Hardware 
-------- 
Tenma 72-2550 & 72-13360 can be used for testing and are both available at Hansonlab


Discussion / hints:

Fix patch:
diff --git a/.github/badges/mypy.svg b/.github/badges/mypy.svg
index 6e625d92..0411eb05 100644
--- a/.github/badges/mypy.svg
+++ b/.github/badges/mypy.svg
@@ -1,23 +1,23 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="79" height="20">
+<svg xmlns="http://www.w3.org/2000/svg" width="71" height="20">
     <linearGradient id="b" x2="0" y2="100%">
         <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
         <stop offset="1" stop-opacity=".1"/>
     </linearGradient>
     <mask id="anybadge_1">
-        <rect width="79" height="20" rx="3" fill="#fff"/>
+        <rect width="71" height="20" rx="3" fill="#fff"/>
     </mask>
     <g mask="url(#anybadge_1)">
         <path fill="#555" d="M0 0h41v20H0z"/>
-        <path fill="#4C1" d="M41 0h38v20H41z"/>
-        <path fill="url(#b)" d="M0 0h79v20H0z"/>
+        <path fill="#E05D44" d="M41 0h30v20H41z"/>
+        <path fill="url(#b)" d="M0 0h71v20H0z"/>
     </g>
     <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
         <text x="21.5" y="15" fill="#010101" fill-opacity=".3">mypy</text>
         <text x="20.5" y="14">mypy</text>
     </g>
     <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
-        <text x="61.0" y="15" fill="#010101" fill-opacity=".3">pass</text>
-        <text x="60.0" y="14">pass</text>
+        <text x="57.0" y="15" fill="#010101" fill-opacity=".3">fail</text>
+        <text x="56.0" y="14">fail</text>
     </g>
 </svg>
diff --git a/.github/badges/pylint.svg b/.github/badges/pylint.svg
index 48c01e83..deceec53 100644
--- a/.github/badges/pylint.svg
+++ b/.github/badges/pylint.svg
@@ -17,7 +17,7 @@
         <text x="22.0" y="14">pylint</text>
     </g>
     <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
-        <text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.21</text>
-        <text x="62.0" y="14">9.21</text>
+        <text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.22</text>
+        <text x="62.0" y="14">9.22</text>
     </g>
 </svg>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6cf78af..01023faf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - The `transport.py` was introduced with common base class for TCP and UDP protocols, where several implementations are present
   for functions that work the same for both protocols. Some modifications, especially for `read` functions, were required for doing this.
   Other functions were implemented separately.
+- QMI drivers for Tenma 72-series power supply units in `qmi.instruments.tenma`
 
 ### Changed
 - Changed the stopping of contexts in `qmi_proc.proc_stop()` to happen in reverse order to `proc_start()`.
diff --git a/bin/instruments/qmi_tenma_72psu b/bin/instruments/qmi_tenma_72psu
new file mode 100644
index 00000000..02ba7ace
--- /dev/null
+++ b/bin/instruments/qmi_tenma_72psu
@@ -0,0 +1,150 @@
+#! /usr/bin/env python3
+
+"""Command line client for the Tenma 72-series power supplies."""
+
+import argparse
+from contextlib import nullcontext, AbstractContextManager
+import sys
+
+import qmi
+from qmi.instruments.tenma import *  # This may look like unused in IDE but will import all PSU classes.
+from qmi.utils.context_managers import start_stop, open_close
+
+
+def main() -> int:
+
+    parser = argparse.ArgumentParser()
+    parser.description = "Command line client for the Tenma 72 series Power Supply Units."
+
+    parser.add_argument("--model", type=str, help="The model number of the Tenma 72 series PSU (e.g. 2550)")
+    parser.add_argument("--channel", type=int, help="Channel or memory slot of instrument", const=None)
+
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument("--udp", type=str, help="IP address of device.")
+    group.add_argument("--serial", type=str, help="Serial address of device (COMx, /dev/ttySx).")
+
+    parser.add_argument("--idn", action="store_true", help="Get instrument identification")
+    parser.add_argument("--status", action="store_true", help="Get instrument status")
+    # setters/getters
+    parser.add_argument("--current", type=str, help="Get/Set current in Amperes", nargs='?', const='get',)
+    parser.add_argument("--voltage", type=str, help="Get/Set voltage in Volts", nargs='?', const='get',)
+    parser.add_argument("--output", action="store_true", help="Enable output", default=None)
+    parser.add_argument("--no-output", dest="output", action="store_false", help="Disable output")
+    parser.add_argument("--ip", type=str, help="Get/Set IP address", nargs='?', const='get')
+    parser.add_argument("--dhcp", type=str, help="Get/Set IP address", nargs='?', const='get')
+    parser.add_argument("--port", type=str, help="Get/Set IP port number", nargs='?', const='get')
+    parser.add_argument("--mask", type=str, help="Get/Set IP subnet mask", nargs='?', const='get')
+    parser.add_argument("--gateway", type=str, help="Get/Set IP gateway", nargs='?', const='get')
+    args = parser.parse_args()
+
+    instr: getattr(qmi.instruments.tenma, f"Tenma72_{args.model}")
+    with start_stop(qmi, "tenma72_psu_client", console_loglevel="WARNING"), parse_instrument_source(args) as instr:
+        if args.idn:
+            idn = instr.get_idn()
+            print("IDN:", idn)
+
+        if args.status:
+            print("Status of the instrument is:")
+            print(instr.get_status())
+
+        channel = args.channel
+        if args.current is not None:
+            if args.current == "get":
+                print("Current is", instr.read_current(channel), "A")
+
+            else:
+                current = float(args.current)
+                print(f"Setting current to {current:.3f} A")
+                instr.set_current(current, channel)
+
+        if args.voltage is not None:
+            if args.voltage == "get":
+                print("Voltage is", instr.read_voltage(channel), "V")
+
+            else:
+                voltage = float(args.voltage)
+                print(f"Setting voltage to {voltage:.3f} A")
+                instr.set_voltage(voltage, channel)
+
+        if args.output is not None:
+            if args.output:
+                print("Enabling output.")
+            else:
+                print("Disabling output.")
+
+            instr.enable_output(args.output)
+
+        if args.dhcp is not None:
+            if args.dhcp == "get":
+                print("DHCP state is", instr.get_dhcp())
+
+            else:
+                print(f"Setting DHCP state to {args.dhcp}")
+                instr.set_dhcp(args.dhcp)
+
+        if args.ip is not None:
+            if args.ip == "get":
+                print("IP address is", instr.get_ip_address())
+
+            else:
+                print(f"Setting IP address to {args.ip}")
+                instr.set_ip_address(args.ip)
+
+        if args.port is not None:
+            if args.port == "get":
+                print("IP port number is", instr.get_ip_port())
+
+            else:
+                print(f"Setting IP port number to {args.port}")
+                instr.set_ip_port(args.port)
+
+        if args.mask is not None:
+            if args.mask == "get":
+                print("IP subnet mask is", instr.get_subnet_mask())
+
+            else:
+                print(f"Setting IP subnet mask to {args.mask}")
+                instr.set_subnet_mask(args.mask)
+
+        if args.gateway is not None:
+            if args.gateway == "get":
+                print("IP gateway is", instr.get_gateway_address())
+
+            else:
+                print(f"Setting IP gateway to {args.gateway}")
+                instr.set_gateway_address(args.gateway)
+
+    return 0
+
+
+def parse_instrument_source(args) -> AbstractContextManager:
+    # make the instrument
+    instr = getattr(qmi.instruments.tenma, f"Tenma72_{args.model}")
+    if args.udp is not None:
+        port = args.port if args.port != "get" and args.port is not None else 18190  # 18190 is the default in the manual
+        return open_close(
+            qmi.make_instrument(
+                instrument_name=f"tenma72_{args.model}",
+                instrument_class=instr,
+                transport=f"udp:{args.udp}:{port}"
+            )
+        )
+    elif args.serial is not None:
+        return open_close(
+            qmi.make_instrument(
+                instrument_name=f"tenma72_{args.model}",
+                instrument_class=instr,
+                transport=f"serial:{args.serial}"
+            )
+        )
+
+    # get the instrument
+    if args.address is not None:
+        qmi.context().connect_to_peer(args.address.split('.')[0])
+        return nullcontext(qmi.get_instrument(args.address))
+
+    raise ValueError("Expected a source for the instrument!")
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/qmi/instruments/tenma/__init__.py b/qmi/instruments/tenma/__init__.py
new file mode 100644
index 00000000..b7e7e5c5
--- /dev/null
+++ b/qmi/instruments/tenma/__init__.py
@@ -0,0 +1,9 @@
+"""
+Tenma (Newark Electronics) power supply units.
+
+The qmi.instruments.tenma package provides support for:
+- Series 72 power supply unit groups
+  - 72-2535, 72-2540, 72-2545, 72-2550, 72-2925, 72-2930, 72-2935, 72-2940 & 72-10480
+  - 72-13350 & 72-13360
+"""
+from qmi.instruments.tenma.psu_72 import *
diff --git a/qmi/instruments/tenma/psu_72.py b/qmi/instruments/tenma/psu_72.py
new file mode 100644
index 00000000..93160a0d
--- /dev/null
+++ b/qmi/instruments/tenma/psu_72.py
@@ -0,0 +1,562 @@
+"""Instrument driver for the Tenma 72-series power supply units.
+
+Two main model groups in this series are the models similar to 2550 (selected as one base class) and
+models similar to 13350 (selected also as a base class). Both groups work with USB-to-serial communication
+but the second group has also possibility to use UDP communication. For that IP LAN communication protocol
+with related functionalities is present, which can be used either through USB or UDP connection.
+
+Some differences are present, even though the basis is the same, as implemented in the Tenma72_Base class:
+- The group based on 13350 model require an EOL character (`\n`, `\r`) to finish the commands while group
+  based on 2550 model doesn't.
+- The group based on 13350 model does not work when providing the channel number to the serial command, while
+  the commands in group based on 2550 model they do.
+- For example, a command "ISET1:3.0" works for 2550 group, while "ISET:3.0\n" works for 13350 group.
+"""
+from dataclasses import dataclass
+from enum import Enum
+import logging
+import re
+from time import sleep
+from typing import Any, Dict, Optional
+
+from qmi.core.context import QMI_Context
+from qmi.core.exceptions import QMI_InstrumentException
+from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification
+from qmi.core.rpc import rpc_method
+from qmi.core.transport import create_transport
+
+# Global variable holding the logger for this module.
+_logger = logging.getLogger(__name__)
+
+
+@dataclass
+class TenmaChannelMode:
+    value: bool
+    name: str = "C.C"
+
+    def __post_init__(self):
+        if self.value:
+            self.name = "C.V"
+
+
+class TrackingState(Enum):
+    INDEPENDENT = 0
+    TRACKING_SERIES = 1
+    TRACKING_PARALLEL = 2
+    UNKNOWN = 99999
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class Tenma72_Base(QMI_Instrument):
+    """The base class for all Tenma model 72 power supply units.
+
+    Attributes:
+        BUFFER_SIZE:      The default buffer size for 'read'
+        DEFAULT_BAUDRATE: The default baudrate in case of serial connection is used (Baud).
+        MAX_VOLTAGE:      The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT:      The maximum current that can be set with the PSU (Amperes).
+        SEND_SLEEP_TIME:  A default sleep time for sending data.
+        READ_TIMEOUT:     A default timeout for reading data.
+    """
+    BUFFER_SIZE = 50
+    DEFAULT_BAUDRATE = 0
+    MAX_VOLTAGE = 0
+    MAX_CURRENT = 0
+    SEND_SLEEP_TIME = 0.02
+    READ_TIMEOUT = 0.2
+
+    def __init__(self, context: QMI_Context, name: str, transport: str) -> None:
+        super().__init__(context, name)
+        if transport.startswith("serial"):
+            self._transport = create_transport(
+                transport, default_attributes={"baudrate": self.DEFAULT_BAUDRATE}
+            )
+        elif transport.startswith("udp"):
+            self._transport = create_transport(transport)
+        else:
+            raise QMI_InstrumentException(f"Transport type {transport} not valid!")
+
+    def _send(self, cmd: str) -> None:
+        """Writes a command to the Tenma, just an ascii string
+
+        Parameters:
+            cmd: The command to write to the device.
+        """
+        _logger.info("[%s] Sending message %s", self._name, cmd)
+        self._transport.write(bytes(cmd, "ascii"))
+        sleep(self.SEND_SLEEP_TIME)  # Sleep some bits to let the message arrive
+
+    def _read(self) -> str:
+        """Read the response from the device, try until response received or time-out.
+
+        Returns:
+            response: Decoded response string.
+        """
+        response = self._transport.read_until_timeout(self.BUFFER_SIZE, self.READ_TIMEOUT)
+        _logger.info("[%s] Read message %s", self._name, response)
+        return response.decode('ascii')
+
+    def _enable_output(self, output: bool, command: str) -> None:
+        """Internal function to further communicate the output state to instrument.
+
+        Parameters:
+            output:  The expected output state.
+            command: The enable command string.
+        """
+        self._send(command)
+        status = self.get_status()
+        if status["OutputEnabled"] is not output:
+            _logger.exception(
+                "[%s] Tried to set output with %s but got %s", self._name, command, status["OutputEnabled"]
+            )
+            raise QMI_InstrumentException("Setting output failed for an unknown reason")
+
+    @rpc_method
+    def open(self) -> None:
+        self._check_is_closed()
+        _logger.info("Opening connection to %s", self._name)
+        self._transport.open()
+        super().open()
+
+    @rpc_method
+    def close(self) -> None:
+        self._check_is_open()
+        _logger.info("Closing connection to %s", self._name)
+        super().close()
+        self._transport.close()
+
+    @rpc_method
+    def get_idn(self) -> QMI_InstrumentIdentification:
+        """Get instrument identification info.
+
+        Returns:
+            QMI_InstrumentIdentification: NamedTuple with vendor, model, serial number and SW version info.
+        """
+        self._send("*IDN?")
+        idn = self._read()
+        # Find all relevant info from response. Should be of format TENMA 72-2535 SN:1231345 V2.0
+        pattern = re.compile(r'(\b\w+\b[^ ]*|\b72[^ ]*|V[^ ]*|SN:[^ ]*)')
+
+        # Find all matches in the input string
+        matches = pattern.findall(idn)
+
+        # Extract the relevant parts from the matches
+        vendor = matches[0] if matches else None
+        model = next((match for match in matches[1:] if match.startswith('72')), None)
+        version = next((match for match in matches[1:] if match.startswith('V')), None)
+        serial = next((match for match in matches[1:] if match.startswith('SN:')), None)
+
+        if None in (vendor, model, version, serial):
+            _logger.debug("[%s] *IDN? returned %s and matching failed", self._name, idn)
+            raise QMI_InstrumentException("(Full) instrument identification failed!")
+
+        return QMI_InstrumentIdentification(
+            vendor=vendor,
+            model=model,
+            serial=serial.strip()[3:],
+            version=version
+        )
+
+    @rpc_method
+    def get_status(self) -> Dict[str, Any]:
+        """This method needs to be implemented in the inheriting classes."""
+        raise NotImplementedError()
+
+    @rpc_method
+    def get_current(self, output_channel: Optional[int] = None) -> float:
+        """Get the current setting.
+
+        Parameters:
+            output_channel: The channel to send the inquiry to.
+
+        Returns:
+            Current setting of the channel in Amperes.
+        """
+        ch = output_channel or ""
+        command = f"ISET{ch}?"
+        self._send(command)
+        # Response has appended sixth byte from *IDN? to voltage reading due to a firmware bug
+        return float(self._read()[:5])
+
+    @rpc_method
+    def set_current(self, current: float, output_channel: Optional[int] = None) -> None:
+        """Set the current setting.
+
+        Parameters:
+            current:        Current for the channel in Amperes.
+            output_channel: The channel to send the inquiry to.
+
+        Raises:
+            ValueError:     If the current setting is not between 0 and self.MAX_CURRENT.
+        """
+        if not 0 <= current <= self.MAX_CURRENT:
+            raise ValueError(f"Invalid current setting {current}, not between 0 and {self.MAX_CURRENT} A")
+
+        ch = output_channel or ""
+        command = f"ISET{ch}:{current:.3f}"
+        self._send(command)
+
+    @rpc_method
+    def get_voltage(self, output_channel: Optional[int] = None) -> float:
+        """Get the voltage setting.
+
+        Parameters:
+            output_channel: The channel to send the inquiry to.
+
+        Returns:
+            Voltage setting of the channel in Volts.
+        """
+        ch = output_channel or ""
+        command = f"VSET{ch}?"
+        self._send(command)
+        return float(self._read())
+
+    @rpc_method
+    def set_voltage(self, voltage: float, output_channel: Optional[int] = None) -> None:
+        """Set the voltage setting.
+
+        Parameters:
+            voltage:        Voltage setting for the channel in Volts.
+            output_channel: The channel to send the inquiry to.
+
+        Raises:
+            ValueError:     If the input voltage setting is not between 0 and self.MAX_VOLTAGE.
+        """
+        if not 0 <= voltage <= self.MAX_VOLTAGE:
+            raise ValueError(f"Invalid current setting {voltage}, not between 0 and {self.MAX_VOLTAGE} V")
+
+        ch = output_channel or ""
+        command = f"VSET{ch}:{voltage:.3f}"
+        self._send(command)
+
+    @rpc_method
+    def enable_output(self, output: bool) -> None:
+        """Enable or disable output from the PSU.
+
+        Parameters:
+            output: Boolean value to either set output ON (True) or OFF (False).
+        """
+        raise NotImplementedError()
+
+
+class Tenma72_2550(Tenma72_Base):
+    """Instrument driver for the Tenma 72-2550. The driver is tested with this model, but the respective
+    manual is also for models 72-2535, 72-2540, 72-2545, 72-2925, 72-2930, 72-2935, 72-2940 & 72-10480.
+
+    This driver can be used only with (USB-to-)serial communications.
+
+    Attributes:
+        DEFAULT_BAUDRATE: The default baudrate in case of serial connection is used (Baud).
+        MAX_VOLTAGE:      The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT:      The maximum current that can be set with the PSU (Amperes).
+    """
+    DEFAULT_BAUDRATE = 9600
+    MAX_VOLTAGE = 60
+    MAX_CURRENT = 3
+
+    @rpc_method
+    def get_status(self) -> Dict[str, Any]:
+        """Get the power supply status as a dictionary of status values.
+
+        Dictionary composition is:
+            Ch1Mode:       "C.V" | "C.C"
+            Ch2Mode:       "C.V" | "C.C"
+            Tracking:      xx, where xx is byte
+                             * 00 = Independent
+                             * 01 = Tracking series
+                             * 10 = Tracking parallel
+            OutputEnabled: True | False
+        
+        Returns: 
+            Dictionary of status values.
+        """
+        self._send("STATUS?")
+        statusByte = ord(self._transport.read(1, 1))  # Read response byte
+        ch1mode = bool(statusByte & 0x01)
+        ch2mode = bool(statusByte & 0x02)
+        tracking = TrackingState((statusByte & 0x0C) >> 2)
+        output_enabled = statusByte & 0x40
+
+        return {
+            "Ch1Mode": TenmaChannelMode(ch1mode).name,
+            "Ch2Mode": TenmaChannelMode(ch2mode).name,
+            "Tracking": tracking.name.replace("_", " ").title(),
+            "OutputEnabled": bool(output_enabled),
+        }
+
+    @rpc_method
+    def enable_output(self, output: bool) -> None:
+        command = f"OUT{int(output)}"
+        self._enable_output(output, command)
+
+
+class Tenma72_2535(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2535, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 3
+
+
+class Tenma72_2540(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2540, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 5
+
+
+class Tenma72_2545(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2545, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 60
+    MAX_CURRENT = 2
+
+
+class Tenma72_2925(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2925, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 5
+
+
+class Tenma72_2930(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2930, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 10
+
+
+class Tenma72_2935(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2935, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 60
+    MAX_CURRENT = 5
+
+
+class Tenma72_2940(Tenma72_2550):
+    """Instrument driver for the Tenma 72-2940, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 60
+    MAX_CURRENT = 5
+
+
+class Tenma72_10480(Tenma72_2550):
+    """Instrument driver for the Tenma 72-10480, child of 72-2550.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 3
+
+
+class Tenma72_13350(Tenma72_Base):
+    """Instrument driver for the Tenma 72-13350. The driver is tested with this model, but manual is
+    also for the model 72-13360.
+
+    This driver can be used with (USB-to-)serial and UDP communications.
+
+    Note that this model has extra functionalities related to the IP LAN Communication Protocol.
+    Also note that this model does not work with channel number input, like 2550 model-based PSUs.
+    """
+    MAX_VOLTAGE = 30
+    MAX_CURRENT = 30
+    DEFAULT_BAUDRATE = 115200
+
+    def __init__(self, context: QMI_Context, name: str, transport: str) -> None:
+        super().__init__(context, name, transport)
+        self.serial_eol = "\n"
+
+    def _send(self, cmd: str) -> None:
+        cmd = cmd + self.serial_eol
+        return super()._send(cmd)
+
+    @rpc_method
+    def get_status(self) -> Dict[str, Any]:
+        """Get the power supply status as a dictionary of status values.
+
+        Dictionary composition is:
+            "ChannelMode ":  "C.V" | "C.C"
+            "OutputEnabled": True | False
+            "V/C priority ": "Current priority" | "Voltage priority"
+            "Beep":          True | False
+            "Lock":          True | False
+
+        Returns:
+            Dictionary of status values.
+        """
+        self._send("STATUS?")
+        status_bytes = self._transport.read(2, 2)  # Read response byte
+        # 72-13350 sends two bytes back, the second being '\n'
+        status = status_bytes[0]
+
+        ch1mode = bool(status & 0x01)
+        output_enabled = bool(status & 0x02)
+        current_priority = status & 0x04
+        beep = bool(status & 0x10)
+        lock = bool(status & 0x20)
+
+        return {
+            "ChannelMode": TenmaChannelMode(ch1mode).name,
+            "OutputEnabled": output_enabled,
+            "V/C priority": "Current priority" if current_priority else "Voltage priority",
+            "Beep": beep,
+            "Lock": lock,
+        }
+
+    @rpc_method
+    def enable_output(self, output: bool) -> None:
+        command = f"OUT:{int(output)}"
+        self._enable_output(output, command)
+
+    @rpc_method
+    def get_dhcp(self) -> int:
+        """Use the IP LAN command to see if DHCP is enabled.
+
+        Returns:
+            dhcp: The current DHCP enabled state.
+        """
+        cmd = ":SYST:DHCP?"
+        self._send(cmd)
+        dhcp = self._read()
+        return int(dhcp)
+
+    @rpc_method
+    def set_dhcp(self, dhcp: int) -> None:
+        """Use the IP LAN command to set a DHCP enabled state.
+
+        Parameters:
+            dhcp: New DHCP state. 0 is disabled, 1 is enabled.
+        """
+        cmd = f":SYST:DHCP {dhcp}"
+        self._send(cmd)
+
+    @rpc_method
+    def get_ip_address(self) -> str:
+        """Use the IP LAN command to get the current IP address of the device.
+
+        Returns:
+            ip_address: The current IP address of the device.
+        """
+        cmd = ":SYST:IPAD?"
+        self._send(cmd)
+        ip_address = self._read()
+        return ip_address
+
+    @rpc_method
+    def set_ip_address(self, ip_address) -> None:
+        """Use the IP LAN command to set a new IP address for the device.
+
+        Parameters:
+            ip_address: A new static IP address for the device.
+        """
+        cmd = f":SYST:IPAD {ip_address}"
+        self._send(cmd)
+
+    @rpc_method
+    def get_subnet_mask(self) -> str:
+        """Use the IP LAN command to get the current subnet mask address of the device.
+
+        Returns:
+            subnet_mask: The current subnet mask address of the device.
+        """
+        cmd = ":SYST:SMASK?"
+        self._send(cmd)
+        subnet_mask = self._read()
+        return subnet_mask
+
+    @rpc_method
+    def set_subnet_mask(self, subnet_mask) -> None:
+        """Use the IP LAN command to set a new subnet mask address for the device.
+
+        Parameters:
+            subnet_mask: A new subnet mask address for the device.
+        """
+        cmd = f":SYST:SMASK {subnet_mask}"
+        self._send(cmd)
+
+    @rpc_method
+    def get_gateway_address(self) -> str:
+        """Use the IP LAN command to get the current gateway address of the device.
+
+        Returns:
+            gateway: The current static gateway address of the device.
+        """
+        cmd = ":SYST:GATE?"
+        self._send(cmd)
+        gateway = self._read()
+        return gateway
+
+    @rpc_method
+    def set_gateway_address(self, gateway) -> None:
+        """Use the IP LAN command to set a new gateway address for the device.
+
+        Parameters:
+            gateway: A new static gateway address for the device.
+        """
+        cmd = f":SYST:GATE {gateway}"
+        self._send(cmd)
+
+    @rpc_method
+    def get_ip_port(self) -> int:
+        """Use the IP LAN command to get the current IP port number of the device.
+
+        Returns:
+            ip_port: The current IP port number of the device.
+        """
+        cmd = ":SYST:PORT?"
+        self._send(cmd)
+        ip_port = self._read()
+        return int(ip_port)
+
+    @rpc_method
+    def set_ip_port(self, ip_port: int) -> None:
+        """Use the IP LAN command to set a new IP port number for the device.
+
+        Parameters:
+            ip_port: A new static IP port number for the device.
+        """
+        cmd = f":SYST:PORT {ip_port}"
+        self._send(cmd)
+
+
+class Tenma72_13360(Tenma72_13350):
+    """Instrument driver for the Tenma 72-13360, child of 72-13350.
+
+    Attributes:
+        MAX_VOLTAGE: The maximum voltage that can be set with the PSU (Volts).
+        MAX_CURRENT: The maximum current that can be set with the PSU (Amperes).
+    """
+    MAX_VOLTAGE = 60
+    MAX_CURRENT = 15


Test patch:
diff --git a/tests/instruments/tenma/__init__.py b/tests/instruments/tenma/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/instruments/tenma/test_psu_72.py b/tests/instruments/tenma/test_psu_72.py
new file mode 100644
index 00000000..9a026046
--- /dev/null
+++ b/tests/instruments/tenma/test_psu_72.py
@@ -0,0 +1,569 @@
+""" Testcase of the Tenma series 72 power supply units."""
+import logging
+import unittest
+from unittest.mock import call, patch
+
+from qmi.core.exceptions import QMI_InstrumentException
+from qmi.core.transport import QMI_UdpTransport
+from qmi.instruments.tenma import Tenma72_2550, Tenma72_13350
+from qmi.instruments.tenma.psu_72 import Tenma72_Base
+
+from tests.patcher import PatcherQmiContext as QMI_Context
+
+
+class TestTenma72_BaseInit(unittest.TestCase):
+    """ Testcase of the Tenma72_Base class initialization"""
+
+    def setUp(self):
+        # Add patches
+        patcher = patch('qmi.instruments.tenma.psu_72.create_transport', spec=QMI_UdpTransport)
+        self._transport_mock = patcher.start().return_value
+        self.addCleanup(patcher.stop)
+
+    def test_init(self):
+        """See that __init__ excepts when invalid type of transport is given."""
+        # Arrange
+        ok_transport = "serial:/dev/ttyS0"
+        ok_transport_2 = "udp:123.45.67.8"
+        nok_transport = "tcp:123.45.67.8:1234"  # TCP cannot be used
+        # Act
+        Tenma72_Base(
+            QMI_Context("Test_tenma_base_init"), "tenma_base_ok", ok_transport
+        )
+        Tenma72_Base(
+            QMI_Context("Test_tenma_base_init2"), "tenma_base_ok2", ok_transport_2
+        )
+        # Assert
+        with self.assertRaises(QMI_InstrumentException):
+            Tenma72_Base(
+                QMI_Context("Test_tenma_base_init_nok"), "tenma_base_nok", nok_transport
+            )
+
+
+class TestTenma72_Base(unittest.TestCase):
+    """ Testcase of the Tenma72_Base class """
+
+    def setUp(self):
+        # Add patches
+        patcher = patch('qmi.instruments.tenma.psu_72.create_transport', spec=QMI_UdpTransport)
+        self._transport_mock = patcher.start().return_value
+        self.addCleanup(patcher.stop)
+        # Make DUT
+        self.psu: Tenma72_Base = Tenma72_Base(QMI_Context("TestSiglentTenma72_Base"), Tenma72_Base, "udp:123.45.67.8")
+        self.psu.open()
+
+    def tearDown(self):
+        self.psu.close()
+
+    def test_get_idn(self):
+        """ Test case for `get_idn()` function. """
+        # arrange
+        expected_vendor = "TENMA"
+        expected_model = "72-2345"
+        expected_serial = "1231345"
+        expected_version = "V2.0"
+        self._transport_mock.read_until_timeout.return_value = "TENMA 72-2345 SN:1231345 V2.0".encode("ascii")
+        expected_calls = [call.read_until_timeout(Tenma72_Base.BUFFER_SIZE, 0.2)]  # 0.2 = default timeout
+        # act
+        idn = self.psu.get_idn()
+        # assert
+        self.assertEqual(expected_calls, self._transport_mock.read_until_timeout.call_args_list)
+        self.assertEqual(expected_vendor, idn.vendor)
+        self.assertEqual(expected_model, idn.model)
+        self.assertEqual(expected_serial, idn.serial)
+        self.assertEqual(expected_version, idn.version)
+
+    def test_get_idn_no_reply_excepts(self):
+        """ Test case for `get_idn()` function. """
+        # arrange
+        self._transport_mock.read_until_timeout.return_value = "".encode("ascii")
+        # act & assert
+        with self.assertRaises(QMI_InstrumentException):
+            self.psu.get_idn()
+
+    def test_get_status_errs(self):
+        """Test case for not implemented get_status() function."""
+        with self.assertRaises(NotImplementedError):
+            self.psu.get_status()
+
+    def test_read_current(self):
+        """Test base read_current(...) method with a channel number and without."""
+        # Arrange
+        cmd_1 = "ISET?"  # No channel
+        ch_2 = 2
+        cmd_2 = f"ISET{ch_2}?"
+        expected_current = 0.123
+        self._transport_mock.read_until_timeout.return_value = f"{expected_current:.5f}".encode("ascii")
+        # Act
+        current = self.psu.get_current()  # No channel
+        # Assert
+        self.assertEqual(expected_current, current)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd_1, "ascii"))
+        self._transport_mock.write.reset_mock()
+        # Act
+        current = self.psu.get_current(ch_2)
+        # Assert
+        self.assertEqual(expected_current, current)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd_2, "ascii"))
+
+    def test_read_voltage(self):
+        """Test base read_voltage(...) method with a channel number and without."""
+        # Arrange
+        cmd_1 = "VSET?"  # No channel
+        ch_2 = 2
+        cmd_2 = f"VSET{ch_2}?"
+        expected_voltage = 1.230
+        self._transport_mock.read_until_timeout.return_value = f"{expected_voltage:.5f}".encode("ascii")
+        # Act
+        voltage = self.psu.get_voltage()  # No channel
+        # Assert
+        self.assertEqual(expected_voltage, voltage)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd_1, "ascii"))
+        self._transport_mock.write.reset_mock()
+        # Act
+        voltage = self.psu.get_voltage(ch_2)
+        # Assert
+        self.assertEqual(expected_voltage, voltage)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd_2, "ascii"))
+
+    def test_enable_disable_output(self):
+        """Test enabling and disabling output. At base class sending works but receiving excepts"""
+        # Act
+        with self.assertRaises(NotImplementedError):
+            self.psu.enable_output(True)
+
+        with self.assertRaises(NotImplementedError):
+            self.psu.enable_output(False)
+
+
+class TestTenma72_2550(unittest.TestCase):
+    """ Testcase of the TestTenma72_2550 psu """
+
+    def setUp(self):
+        # Add patches
+        patcher = patch('qmi.instruments.tenma.psu_72.create_transport', spec=QMI_UdpTransport)
+        self._transport_mock = patcher.start().return_value
+        self.addCleanup(patcher.stop)
+        # Make DUT
+        self.psu: Tenma72_2550 = Tenma72_2550(QMI_Context("TestSiglentTenma72_2550"), Tenma72_2550, "serial:COM0")
+        self.psu.open()
+
+    def tearDown(self):
+        self.psu.close()
+
+    def test_get_idn(self):
+        """ Test case for `get_idn()` function. """
+        # arrange
+        expected_vendor = "TENMA"
+        expected_model = "72-2550"
+        expected_serial = "1231345"
+        expected_version = "V2.0"
+        self._transport_mock.read_until_timeout.return_value = "TENMA 72-2550 SN:1231345 V2.0".encode("ascii")
+        expected_calls = [call.read_until_timeout(Tenma72_2550.BUFFER_SIZE, 0.2)]  # 0.2 = default timeout
+        # act
+        idn = self.psu.get_idn()
+        # assert
+        self.assertEqual(expected_calls, self._transport_mock.read_until_timeout.call_args_list)
+        self.assertEqual(expected_vendor, idn.vendor)
+        self.assertEqual(expected_model, idn.model)
+        self.assertEqual(expected_serial, idn.serial)
+        self.assertEqual(expected_version, idn.version)
+
+    def test_get_status(self):
+        """Test case for get_status() function."""
+        # arrange
+        responses = [chr(0x01), chr(0x02), chr(0x03), chr(0x07), chr(0x0B), chr(0x0C), chr(0x40)]
+        self._transport_mock.read.side_effect = responses
+        expected_statuses = [
+            {"Ch1Mode": "C.V", "Ch2Mode": "C.C", "Tracking": "Independent", "OutputEnabled": False},
+            {"Ch1Mode": "C.C", "Ch2Mode": "C.V", "Tracking": "Independent", "OutputEnabled": False},
+            {"Ch1Mode": "C.V", "Ch2Mode": "C.V", "Tracking": "Independent", "OutputEnabled": False},
+            {"Ch1Mode": "C.V", "Ch2Mode": "C.V", "Tracking": "Tracking Series", "OutputEnabled": False},
+            {"Ch1Mode": "C.V", "Ch2Mode": "C.V", "Tracking": "Tracking Parallel", "OutputEnabled": False},
+            {"Ch1Mode": "C.C", "Ch2Mode": "C.C", "Tracking": "Unknown", "OutputEnabled": False},
+            {"Ch1Mode": "C.C", "Ch2Mode": "C.C", "Tracking": "Independent", "OutputEnabled": True}
+        ]
+        # Act
+        for expected in expected_statuses:
+            status = self.psu.get_status()
+            # Assert
+            self.assertDictEqual(expected, status)
+            self._transport_mock.read.assert_called_once_with(1, 1)
+            self._transport_mock.reset_mock()
+
+    def test_set_current(self):
+        """Set current within allowed range."""
+        # Arrange
+        allowed_currents = [Tenma72_2550.MAX_CURRENT, 0]
+        channels = [None, 2]
+        expected_calls = [
+            call.write(f"ISET:{allowed_currents[0]:.3f}".encode("ascii")),
+            call.write(f"ISET{channels[1]}:{allowed_currents[1]:.3f}".encode("ascii"))
+        ]
+        # Act
+        for e, current in enumerate(allowed_currents):
+            self.psu.set_current(current, channels[e])
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_set_current_excepts(self):
+        """See that values out-of-range cause an exception."""
+        # Arrange
+        disallowed_currents = [Tenma72_2550.MAX_CURRENT + 0.1, -0.1]
+        # Act
+        for current in disallowed_currents:
+            with self.assertRaises(ValueError):
+                self.psu.set_current(current)
+
+        self._transport_mock.write.assert_not_called()
+
+    def test_set_voltage(self):
+        """Set voltage within allowed range."""
+        # Arrange
+        allowed_voltages = [Tenma72_2550.MAX_VOLTAGE, 0]
+        channels = [None, 2]
+        expected_calls = [
+            call.write(f"VSET:{allowed_voltages[0]:.3f}".encode("ascii")),
+            call.write(f"VSET{channels[1]}:{allowed_voltages[1]:.3f}".encode("ascii"))
+        ]
+        # Act
+        for e, voltage in enumerate(allowed_voltages):
+            self.psu.set_voltage(voltage, channels[e])
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_set_voltage_excepts(self):
+        """See that values out-of-range cause an exception."""
+        # Arrange
+        disallowed_voltages = [Tenma72_2550.MAX_VOLTAGE + 0.1, -0.1]
+        # Act
+        for voltage in disallowed_voltages:
+            with self.assertRaises(ValueError):
+                self.psu.set_voltage(voltage)
+
+        self._transport_mock.write.assert_not_called()
+
+    def test_enable_disable_output(self):
+        """Test enabling and disabling output. At implemented class this should work."""
+        # Arrange
+        enable_cmd = "OUT1"
+        disable_cmd = "OUT0"
+        self._transport_mock.read.side_effect = [chr(0x40), chr(0x00)]
+        expected_enable_calls = [
+            call(enable_cmd.encode("ascii")),
+            call(b"STATUS?")
+        ]
+        expected_disable_calls = [
+            call(disable_cmd.encode("ascii")),
+            call(b"STATUS?")
+        ]
+        # Act
+        self.psu.enable_output(True)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_enable_calls)
+        self._transport_mock.write.reset_mock()
+
+        # Act
+        self.psu.enable_output(False)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_disable_calls)
+
+    def test_enable_disable_output_excepts(self):
+        """Test enabling or disabling output raises an exception when the check value is not the same as input."""
+        # Suppress logging.
+        logging.getLogger("qmi.instruments.tenma.psu_72").setLevel(logging.CRITICAL)
+
+        # Arrange
+        enable_cmd = "OUT1"
+        disable_cmd = "OUT0"
+        self._transport_mock.read.side_effect = [chr(0x40), chr(0x00)]
+        expected_enable_calls = [
+            call(enable_cmd.encode("ascii")),
+            call(b"STATUS?")
+        ]
+        expected_disable_calls = [
+            call(disable_cmd.encode("ascii")),
+            call(b"STATUS?")
+        ]
+        # Act
+        with self.assertRaises(QMI_InstrumentException):
+            self.psu.enable_output(False)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_disable_calls)
+        self._transport_mock.write.reset_mock()
+
+        # Act
+        with self.assertRaises(QMI_InstrumentException):
+            self.psu.enable_output(True)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_enable_calls)
+
+        logging.getLogger("qmi.instruments.tenma.psu_72").setLevel(logging.NOTSET)
+
+
+class TestTenma72_13350(unittest.TestCase):
+    """ Testcase of the TestTenma72_13350 psu """
+
+    def setUp(self):
+        # Add patches
+        patcher = patch('qmi.instruments.tenma.psu_72.create_transport', spec=QMI_UdpTransport)
+        self._transport_mock = patcher.start().return_value
+        self.addCleanup(patcher.stop)
+        # Make DUT
+        self.psu: Tenma72_13350 = Tenma72_13350(QMI_Context("TestSiglentTenma72_13350"), Tenma72_13350, "udp:not_parsed")
+        self.psu.open()
+
+    def tearDown(self):
+        self.psu.close()
+
+    def test_get_idn(self):
+        """ Test case for `get_idn()` function. """
+        # arrange
+        expected_vendor = "TENMA"
+        expected_model = "72-13350"
+        expected_serial = "1231345"
+        expected_version = "V2.0"
+        self._transport_mock.read_until_timeout.return_value = "TENMA 72-13350 SN:1231345 V2.0".encode("ascii")
+        expected_calls = [call.read_until_timeout(Tenma72_13350.BUFFER_SIZE, 0.2)]  # 0.2 = default timeout
+        # act
+        idn = self.psu.get_idn()
+        # assert
+        self.assertEqual(expected_calls, self._transport_mock.read_until_timeout.call_args_list)
+        self.assertEqual(expected_vendor, idn.vendor)
+        self.assertEqual(expected_model, idn.model)
+        self.assertEqual(expected_serial, idn.serial)
+        self.assertEqual(expected_version, idn.version)
+
+    def test_get_status(self):
+        """Test case for get_status() function."""
+        # arrange
+        responses = [b"\x01\n", b"\x02\n", b"\x04\n", b"\x10\n", b" \n"]  # last is chr(0x20)
+        self._transport_mock.read.side_effect = responses
+        expected_statuses = [
+            {"ChannelMode": "C.V", "OutputEnabled": False, "V/C priority": "Voltage priority", "Beep": False,
+             "Lock": False},
+            {"ChannelMode": "C.C", "OutputEnabled": True, "V/C priority": "Voltage priority", "Beep": False,
+             "Lock": False},
+            {"ChannelMode": "C.C", "OutputEnabled": False, "V/C priority": "Current priority", "Beep": False,
+             "Lock": False},
+            {"ChannelMode": "C.C", "OutputEnabled": False, "V/C priority": "Voltage priority", "Beep": True,
+             "Lock": False},
+            {"ChannelMode": "C.C", "OutputEnabled": False, "V/C priority": "Voltage priority", "Beep": False,
+             "Lock": True}
+        ]
+        # Act
+        for expected in expected_statuses:
+            status = self.psu.get_status()
+            # Assert
+            self.assertDictEqual(expected, status)
+            self._transport_mock.read.assert_called_once_with(2, 2)
+            self._transport_mock.reset_mock()
+
+    def test_set_current(self):
+        """Set current within allowed range."""
+        # Arrange
+        allowed_currents = [Tenma72_13350.MAX_CURRENT, 0]
+        channels = [None, 2]
+        expected_calls = [
+            call.write(f"ISET:{allowed_currents[0]:.3f}\n".encode("ascii")),
+            call.write(f"ISET{channels[1]}:{allowed_currents[1]:.3f}\n".encode("ascii"))
+        ]
+        # Act
+        for e, current in enumerate(allowed_currents):
+            self.psu.set_current(current, channels[e])
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_set_current_excepts(self):
+        """See that values out-of-range cause an exception."""
+        # Arrange
+        disallowed_currents = [Tenma72_13350.MAX_CURRENT + 0.1, -0.1]
+        # Act
+        for current in disallowed_currents:
+            with self.assertRaises(ValueError):
+                self.psu.set_current(current)
+
+        self._transport_mock.write.assert_not_called()
+
+    def test_set_voltage(self):
+        """Set voltage within allowed range."""
+        # Arrange
+        allowed_voltages = [Tenma72_13350.MAX_VOLTAGE, 0]
+        channels = [None, 2]
+        expected_calls = [
+            call.write(f"VSET:{allowed_voltages[0]:.3f}\n".encode("ascii")),
+            call.write(f"VSET{channels[1]}:{allowed_voltages[1]:.3f}\n".encode("ascii"))
+        ]
+        # Act
+        for e, voltage in enumerate(allowed_voltages):
+            self.psu.set_voltage(voltage, channels[e])
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_set_voltage_excepts(self):
+        """See that values out-of-range cause an exception."""
+        # Arrange
+        disallowed_voltages = [Tenma72_13350.MAX_VOLTAGE + 0.1, -0.1]
+        # Act
+        for voltage in disallowed_voltages:
+            with self.assertRaises(ValueError):
+                self.psu.set_voltage(voltage)
+
+        self._transport_mock.write.assert_not_called()
+
+    def test_enable_disable_output(self):
+        """Test enabling and disabling output. At implemented class this should work."""
+        # Arrange
+        enable_cmd = "OUT:1\n"
+        disable_cmd = "OUT:0\n"
+        self._transport_mock.read.side_effect = [b"\x02\n", b"\x00\n"]
+        expected_enable_calls = [
+            call(enable_cmd.encode("ascii")),
+            call(b"STATUS?\n")
+        ]
+        expected_disable_calls = [
+            call(disable_cmd.encode("ascii")),
+            call(b"STATUS?\n")
+        ]
+        # Act
+        self.psu.enable_output(True)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_enable_calls)
+        self._transport_mock.write.reset_mock()
+
+        # Act
+        self.psu.enable_output(False)
+
+        # Assert
+        self._transport_mock.write.assert_has_calls(expected_disable_calls)
+
+    def test_get_dhcp(self):
+        """Get the DHCP state."""
+        # Arrange
+        cmd = ":SYST:DHCP?\n"
+        expected_dhcp = 1
+        self._transport_mock.read_until_timeout.return_value = f"{expected_dhcp}".encode("ascii")
+        # Act
+        state = self.psu.get_dhcp()
+        # Assert
+        self.assertEqual(expected_dhcp, state)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd, "ascii"))
+
+    def test_set_dhcp(self):
+        """Set DHCP on/off."""
+        # Arrange
+        dhcp = 0
+        expected_calls = [
+            call.write(f":SYST:DHCP {dhcp}\n".encode("ascii")),
+        ]
+        # Act
+        self.psu.set_dhcp(dhcp)
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_get_ip_address(self):
+        """Get the IP address."""
+        # Arrange
+        cmd = ":SYST:IPAD?\n"
+        expected_ipad = "192.168.1.90"
+        self._transport_mock.read_until_timeout.return_value = f"{expected_ipad}".encode("ascii")
+        # Act
+        ipad = self.psu.get_ip_address()
+        # Assert
+        self.assertEqual(expected_ipad, ipad)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd, "ascii"))
+
+    def test_set_ip_address(self):
+        """Set an IP address."""
+        # Arrange
+        ip_address = "192.168.1.90"
+        expected_calls = [
+            call.write(f":SYST:IPAD {ip_address}\n".encode("ascii")),
+        ]
+        # Act
+        self.psu.set_ip_address(ip_address)
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_get_subnet_mask(self):
+        """Get the subnet mask."""
+        # Arrange
+        cmd = ":SYST:SMASK?\n"
+        expected_ipad = "255.255.255.0"
+        self._transport_mock.read_until_timeout.return_value = f"{expected_ipad}".encode("ascii")
+        # Act
+        ipad = self.psu.get_subnet_mask()
+        # Assert
+        self.assertEqual(expected_ipad, ipad)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd, "ascii"))
+
+    def test_set_subnet_mask(self):
+        """Set a subnet mask."""
+        # Arrange
+        subnet_mask = "255.255.255.0"
+        expected_calls = [
+            call.write(f":SYST:SMASK {subnet_mask}\n".encode("ascii")),
+        ]
+        # Act
+        self.psu.set_subnet_mask(subnet_mask)
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_get_gateway_address(self):
+        """Get the gateway address."""
+        # Arrange
+        cmd = ":SYST:GATE?\n"
+        expected_gateway = "192.168.0.1"
+        self._transport_mock.read_until_timeout.return_value = f"{expected_gateway}".encode("ascii")
+        # Act
+        gateway = self.psu.get_gateway_address()
+        # Assert
+        self.assertEqual(expected_gateway, gateway)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd, "ascii"))
+
+    def test_set_gateway_address(self):
+        """Set a gateway address."""
+        # Arrange
+        gateway_address = "192.168.0.1"
+        expected_calls = [
+            call.write(f":SYST:GATE {gateway_address}\n".encode("ascii")),
+        ]
+        # Act
+        self.psu.set_gateway_address(gateway_address)
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+    def test_get_ip_port(self):
+        """Get the IP port."""
+        # Arrange
+        cmd = ":SYST:PORT?\n"
+        expected_port = 5990
+        self._transport_mock.read_until_timeout.return_value = f"{expected_port}".encode("ascii")
+        # Act
+        port = self.psu.get_ip_port()
+        # Assert
+        self.assertEqual(expected_port, port)
+        self._transport_mock.write.assert_called_once_with(bytes(cmd, "ascii"))
+
+    def test_set_ip_port(self):
+        """Set an IP port."""
+        # Arrange
+        port = 5990
+        expected_calls = [
+            call.write(f":SYST:PORT {port}\n".encode("ascii")),
+        ]
+        # Act
+        self.psu.set_ip_port(port)
+
+        self._transport_mock.write.assert_has_calls(expected_calls)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/patcher.py b/tests/patcher.py
new file mode 100644
index 00000000..3da25dac
--- /dev/null
+++ b/tests/patcher.py
@@ -0,0 +1,33 @@
+"""Generic patcher implementations."""
+from unittest.mock import patch
+
+import qmi
+from qmi.core.context import QMI_Context
+
+
+class PatcherQmiContext:
+    """Patches the qmi_context global variable which results in an instanced qmi_context.
+    Note that only within the context of this patcher the qmi_context is a singleton.
+    """
+
+    def __init__(self, name=None):
+        self.name = name
+        self.qmi_patcher = None
+
+    def start(self, context_name=None, config=None):
+        """Start the qmi_context and patcher."""
+        context_name = context_name or self.name
+        self.qmi_patcher = patch("qmi.core.context_singleton._qmi_context", QMI_Context(context_name, config))
+        self.qmi_patcher.start()
+        qmi.core.context_singleton._qmi_context.start()
+        qmi.core.context_singleton._connect_to_peers()
+
+    def stop(self):
+        """Stop the qmi_context and patcher."""
+        qmi.stop()
+        self.qmi_patcher.stop()
+
+    def make_instrument(self, instrument_name, instrument_class, *args, **kwargs):
+        return qmi.core.context_singleton._qmi_context.make_instrument(
+            instrument_name, instrument_class, *args, **kwargs
+        )