Repository: pymeasure/pymeasure

Issue:
`TypeError` on SerialAdapter `read_bytes(-1)`
When I use `self.read_bytes(-1)` from my instrument using a `SerialAdapter`, I get the error:

> C:\Program Files\Python310\lib\ctypes\__init__.py:66: TypeError

With the traceback:

```
..\..\..\pymeasure\instruments\velleman\velleman_k8090.py:247: in read
    response = self.read_bytes(-1)
..\..\..\pymeasure\instruments\instrument.py:153: in read_bytes
    return self.adapter.read_bytes(count, **kwargs)
..\..\..\pymeasure\adapters\adapter.py:123: in read_bytes
    read = self._read_bytes(count, break_on_termchar, **kwargs)
..\..\..\pymeasure\adapters\serial.py:108: in _read_bytes
    return self.connection.read(1e99 if count == -1 else count, **kwargs)
..\..\..\venv\lib\site-packages\serial\serialwin32.py:278: in read
    buf = ctypes.create_string_buffer(n)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

init = 1e+99, size = None

    def create_string_buffer(init, size=None):
        """create_string_buffer(aBytes) -> character array
        create_string_buffer(anInteger) -> character array
        create_string_buffer(aBytes, anInteger) -> character array
        """
        if isinstance(init, bytes):
            if size is None:
                size = len(init)+1
            _sys.audit("ctypes.create_string_buffer", init, size)
            buftype = c_char * size
            buf = buftype()
            buf.value = init
            return buf
        elif isinstance(init, int):
            _sys.audit("ctypes.create_string_buffer", None, init)
            buftype = c_char * init
            buf = buftype()
            return buf
>       raise TypeError(init)
E       TypeError: 1e+99

C:\Program Files\Python310\lib\ctypes\__init__.py:66: TypeError

```

It looks like reading 1e99 bytes no (longer?) works.

Discussion:

Patch:
diff --git a/docs/api/instruments/index.rst b/docs/api/instruments/index.rst
index 708474d6..cd21133d 100644
--- a/docs/api/instruments/index.rst
+++ b/docs/api/instruments/index.rst
@@ -63,4 +63,5 @@ Instruments by manufacturer:
    thermotron/index
    thorlabs/index
    toptica/index
+   velleman/index
    yokogawa/index
diff --git a/docs/api/instruments/oxfordinstruments/adapters.rst b/docs/api/instruments/oxfordinstruments/adapters.rst
deleted file mode 100644
index 1ad8031e..00000000
--- a/docs/api/instruments/oxfordinstruments/adapters.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-###############################
-Oxford Instruments VISA Adapter
-###############################
-
-.. autoclass:: pymeasure.instruments.oxfordinstruments.OxfordInstrumentsAdapter
-    :members:
-    :show-inheritance:
-
-.. autoclass:: pymeasure.instruments.oxfordinstruments.adapters.OxfordVISAError
-    :members:
-    :show-inheritance:
diff --git a/docs/api/instruments/oxfordinstruments/base.rst b/docs/api/instruments/oxfordinstruments/base.rst
new file mode 100644
index 00000000..59da2dcc
--- /dev/null
+++ b/docs/api/instruments/oxfordinstruments/base.rst
@@ -0,0 +1,11 @@
+##################################
+Oxford Instruments Base Instrument
+##################################
+
+.. autoclass:: pymeasure.instruments.oxfordinstruments.base.OxfordInstrumentsBase
+    :members:
+    :show-inheritance:
+
+.. autoclass:: pymeasure.instruments.oxfordinstruments.base.OxfordVISAError
+    :members:
+    :show-inheritance:
diff --git a/docs/api/instruments/oxfordinstruments/index.rst b/docs/api/instruments/oxfordinstruments/index.rst
index 6f86e1be..a215bb4b 100644
--- a/docs/api/instruments/oxfordinstruments/index.rst
+++ b/docs/api/instruments/oxfordinstruments/index.rst
@@ -9,7 +9,7 @@ This section contains specific documentation on the Oxford Instruments instrumen
 .. toctree::
    :maxdepth: 2
 
-   adapters
+   base
    ITC503
    IPS120_10
    PS120_10
diff --git a/docs/api/instruments/velleman/index.rst b/docs/api/instruments/velleman/index.rst
new file mode 100644
index 00000000..6b949c62
--- /dev/null
+++ b/docs/api/instruments/velleman/index.rst
@@ -0,0 +1,12 @@
+.. module:: pymeasure.instruments.velleman
+
+########
+Velleman
+########
+
+This section contains specific documentation on the Velleman instruments that are implemented. If you are interested in an instrument not included, please consider :doc:`adding the instrument </dev/adding_instruments/index>`.
+
+.. toctree::
+   :maxdepth: 2
+
+   k8090
diff --git a/docs/api/instruments/velleman/k8090.rst b/docs/api/instruments/velleman/k8090.rst
new file mode 100644
index 00000000..bbc134e6
--- /dev/null
+++ b/docs/api/instruments/velleman/k8090.rst
@@ -0,0 +1,10 @@
+####################################
+Velleman K8090 8-channel relay board
+####################################
+
+.. autoclass:: pymeasure.instruments.velleman.VellemanK8090
+    :members:
+    :show-inheritance:
+
+.. autoclass:: pymeasure.instruments.velleman.VellemanK8090Switches
+    :show-inheritance:
diff --git a/pymeasure/adapters/serial.py b/pymeasure/adapters/serial.py
index dfec482c..ac119758 100644
--- a/pymeasure/adapters/serial.py
+++ b/pymeasure/adapters/serial.py
@@ -94,7 +94,7 @@ class SerialAdapter(Adapter):
         """Read a certain number of bytes from the instrument.
 
         :param int count: Number of bytes to read. A value of -1 indicates to
-            read from the whole read buffer.
+            read from the whole read buffer (waits for timeout).
         :param bool break_on_termchar: Stop reading at a termination character.
         :param \\**kwargs: Keyword arguments for the connection itself.
         :returns bytes: Bytes response of the instrument (including termination).
@@ -103,9 +103,25 @@ class SerialAdapter(Adapter):
             return self.connection.read_until(self.read_termination.encode(),
                                               count if count > 0 else None,
                                               **kwargs)
+        elif count >= 0:
+            return self.connection.read(count, **kwargs)
         else:
-            # At -1 we read a very large number of bytes, which can be considered the whole buffer.
-            return self.connection.read(1e99 if count == -1 else count, **kwargs)
+            # For -1 we empty the buffer completely
+            return self._read_bytes_until_timeout()
+
+    def _read_bytes_until_timeout(self, chunk_size=256, **kwargs):
+        """Read from the serial until a timeout occurs, regardless of the number of bytes.
+
+        :chunk_size: The number of bytes attempted to in a single transaction.
+            Multiple of these transactions will occur.
+        """
+        # `Serial.readlines()` has an unpredictable timeout, see PR #866
+        data = bytes()
+        while True:
+            chunk = self.connection.read(chunk_size, **kwargs)
+            data += chunk
+            if len(chunk) < chunk_size:  # If fewer bytes got returned, we had a timeout
+                return data
 
     def flush_read_buffer(self):
         """Flush and discard the input buffer."""
diff --git a/pymeasure/instruments/__init__.py b/pymeasure/instruments/__init__.py
index 60582694..535e37a1 100644
--- a/pymeasure/instruments/__init__.py
+++ b/pymeasure/instruments/__init__.py
@@ -71,4 +71,5 @@ from . import texio
 from . import thermotron
 from . import thorlabs
 from . import toptica
+from . import velleman
 from . import yokogawa
diff --git a/pymeasure/instruments/oxfordinstruments/__init__.py b/pymeasure/instruments/oxfordinstruments/__init__.py
index db4d8e9c..7a9fc988 100644
--- a/pymeasure/instruments/oxfordinstruments/__init__.py
+++ b/pymeasure/instruments/oxfordinstruments/__init__.py
@@ -23,7 +23,6 @@
 #
 
 
-from .adapters import OxfordInstrumentsAdapter
 from .itc503 import ITC503
 from .ips120_10 import IPS120_10
 from .ps120_10 import PS120_10
diff --git a/pymeasure/instruments/oxfordinstruments/adapters.py b/pymeasure/instruments/oxfordinstruments/base.py
similarity index 77%
rename from pymeasure/instruments/oxfordinstruments/adapters.py
rename to pymeasure/instruments/oxfordinstruments/base.py
index bbfe9431..15946225 100644
--- a/pymeasure/instruments/oxfordinstruments/adapters.py
+++ b/pymeasure/instruments/oxfordinstruments/base.py
@@ -23,9 +23,9 @@
 #
 
 
-from pymeasure.adapters import VISAAdapter
+from pymeasure.instruments import Instrument
 from pyvisa.errors import VisaIOError
-
+from pyvisa import constants as vconst
 import re
 import logging
 
@@ -38,23 +38,37 @@ class OxfordVISAError(Exception):
     pass
 
 
-class OxfordInstrumentsAdapter(VISAAdapter):
-    """Adapter class for the VISA library using PyVISA to communicate
-    with instruments.
+class OxfordInstrumentsBase(Instrument):
+    """Base instrument for devices from Oxford Instruments.
+
     Checks the replies from instruments for validity.
 
-    :param resource_name: VISA resource name that identifies the address
+    :param adapter: A string, integer, or :py:class:`~pymeasure.adapters.Adapter` subclass object
+    :param string name: The name of the instrument. Often the model designation by default.
     :param max_attempts: Integer that sets how many attempts at getting a
         valid response to a query can be made
-    :param kwargs: key-word arguments for constructing a PyVISA Adapter
+    :param \\**kwargs: In case ``adapter`` is a string or integer, additional arguments passed on
+        to :py:class:`~pymeasure.adapters.VISAAdapter` (check there for details).
+        Discarded otherwise.
     """
 
     timeoutError = VisaIOError(-1073807339)
 
     regex_pattern = r"^([a-zA-Z])[\d.+-]*$"
 
-    def __init__(self, resource_name, max_attempts=5, **kwargs):
-        super().__init__(resource_name, **kwargs)
+    def __init__(self, adapter, name="OxfordInstruments Base", max_attempts=5, **kwargs):
+        kwargs.setdefault('read_termination', '\r')
+
+        super().__init__(adapter,
+                         name=name,
+                         includeSCPI=False,
+                         asrl={
+                             'baud_rate': 9600,
+                             'data_bits': 8,
+                             'parity': vconst.Parity.none,
+                             'stop_bits': vconst.StopBits.two,
+                         },
+                         **kwargs)
         self.max_attempts = max_attempts
 
     def ask(self, command):
@@ -72,9 +86,15 @@ class OxfordInstrumentsAdapter(VISAAdapter):
         """
 
         for attempt in range(self.max_attempts):
-            response = super().ask(command)
+            # Skip the checks in "write", because we explicitly want to get an answer here
+            super().write(command)
+            self.wait_for()
+            response = self.read()
 
             if self.is_valid_response(response, command):
+                if command.startswith("R"):
+                    # Remove the leading R of the response
+                    return response.strip("R")
                 return response
 
             log.debug("Received invalid response to '%s': %s", command, response)
@@ -92,7 +112,7 @@ class OxfordInstrumentsAdapter(VISAAdapter):
         raise OxfordVISAError(f"Retried {self.max_attempts} times without getting a valid "
                               "response, maybe there is something worse at hand.")
 
-    def _write(self, command):
+    def write(self, command):
         """Write command to instrument and check whether the reply indicates that the given command
         was not understood.
         The devices from Oxford Instruments reply with '?xxx' to a command 'xxx' if this command is
@@ -103,7 +123,7 @@ class OxfordInstrumentsAdapter(VISAAdapter):
         :raises: :class:`~.OxfordVISAError` if the instrument does not recognise the supplied
             command or if the response of the instrument is not understood
         """
-        super()._write(command)
+        super().write(command)
 
         if not command[0] == "$":
             response = self.read()
@@ -158,4 +178,4 @@ class OxfordInstrumentsAdapter(VISAAdapter):
         return bool(match)
 
     def __repr__(self):
-        return "<OxfordInstrumentsAdapter(resource='%s')>" % self.connection.resource_name
+        return "<OxfordInstrumentsAdapter(adapter='%s')>" % self.adapter.connection.resource_name
diff --git a/pymeasure/instruments/oxfordinstruments/ips120_10.py b/pymeasure/instruments/oxfordinstruments/ips120_10.py
index 8914800d..a123ce52 100644
--- a/pymeasure/instruments/oxfordinstruments/ips120_10.py
+++ b/pymeasure/instruments/oxfordinstruments/ips120_10.py
@@ -30,7 +30,7 @@ from pymeasure.instruments import Instrument
 from pymeasure.instruments.validators import strict_discrete_set
 from pymeasure.instruments.validators import truncated_range
 
-from .adapters import OxfordInstrumentsAdapter
+from .base import OxfordInstrumentsBase
 
 # Setup logging
 log = logging.getLogger(__name__)
@@ -47,7 +47,7 @@ class SwitchHeaterError(ValueError):
     pass
 
 
-class IPS120_10(Instrument):
+class IPS120_10(OxfordInstrumentsBase):
     """Represents the Oxford Superconducting Magnet Power Supply IPS 120-10.
 
     .. code-block:: python
@@ -116,25 +116,10 @@ class IPS120_10(Instrument):
                  field_range=None,
                  **kwargs):
 
-        if isinstance(adapter, (int, str)):
-            kwargs.setdefault('read_termination', '\r')
-            kwargs.setdefault('send_end', True)
-            adapter = OxfordInstrumentsAdapter(
-                adapter,
-                asrl={
-                    'baud_rate': 9600,
-                    'data_bits': 8,
-                    'parity': 0,
-                    'stop_bits': 20,
-                },
-                preprocess_reply=lambda v: v[1:],
-                **kwargs,
-            )
-
         super().__init__(
             adapter=adapter,
             name=name,
-            includeSCPI=False,
+            **kwargs
         )
 
         if switch_heater_heating_delay is not None:
@@ -155,7 +140,6 @@ class IPS120_10(Instrument):
     version = Instrument.measurement(
         "V",
         """ A string property that returns the version of the IPS. """,
-        preprocess_reply=lambda v: v,
     )
 
     control_mode = Instrument.control(
diff --git a/pymeasure/instruments/oxfordinstruments/itc503.py b/pymeasure/instruments/oxfordinstruments/itc503.py
index 978c84b9..0c0560af 100644
--- a/pymeasure/instruments/oxfordinstruments/itc503.py
+++ b/pymeasure/instruments/oxfordinstruments/itc503.py
@@ -32,7 +32,7 @@ from pymeasure.instruments import Instrument
 from pymeasure.instruments.validators import strict_discrete_set, \
     truncated_range, strict_range
 
-from .adapters import OxfordInstrumentsAdapter
+from .base import OxfordInstrumentsBase
 
 
 # Setup logging
@@ -58,7 +58,7 @@ def pointer_validator(value, values):
     return tuple(strict_range(v, values) for v in value)
 
 
-class ITC503(Instrument):
+class ITC503(OxfordInstrumentsBase):
     """Represents the Oxford Intelligent Temperature Controller 503.
 
     .. code-block:: python
@@ -84,25 +84,10 @@ class ITC503(Instrument):
                  max_temperature=1677.7,
                  **kwargs):
 
-        if isinstance(adapter, (int, str)):
-            kwargs.setdefault('read_termination', '\r')
-            kwargs.setdefault('send_end', True)
-            adapter = OxfordInstrumentsAdapter(
-                adapter,
-                asrl={
-                    'baud_rate': 9600,
-                    'data_bits': 8,
-                    'parity': 0,
-                    'stop_bits': 20,
-                },
-                preprocess_reply=lambda v: v[1:],
-                **kwargs,
-            )
-
         super().__init__(
             adapter=adapter,
             name=name,
-            includeSCPI=False,
+            **kwargs,
         )
 
         # Clear the buffer in order to prevent communication problems
diff --git a/pymeasure/instruments/oxfordinstruments/ps120_10.py b/pymeasure/instruments/oxfordinstruments/ps120_10.py
index d11d4c65..03eb0586 100644
--- a/pymeasure/instruments/oxfordinstruments/ps120_10.py
+++ b/pymeasure/instruments/oxfordinstruments/ps120_10.py
@@ -27,12 +27,12 @@ from .ips120_10 import IPS120_10
 
 
 def PS_custom_get_process(v):
-    """convert string to proper float value, for working with the PS 120-10 """
-    return float(v[1:]) * 1e-2
+    """Adjust the received value, for working with the PS 120-10 """
+    return v * 1e-2
 
 
 def PS_custom_set_process(v):
-    """convert float to proper int value, for working with the PS 120-10 """
+    """Convert float to proper int value, for working with the PS 120-10 """
     return int(v * 1e2)
 
 
@@ -100,12 +100,12 @@ class PS120_10(IPS120_10):
 
     current_setpoint_get_process = PS_custom_get_process
     current_setpoint_set_process = PS_custom_set_process
-    current_setpoint_set_command = "J%d"
+    current_setpoint_set_command = "I%d"
 
     field_setpoint_get_process = PS_custom_get_process
     field_setpoint_set_process = PS_custom_set_process
-    field_setpoint_set_command = "T%d"
+    field_setpoint_set_command = "J%d"
 
     sweep_rate_get_process = PS_custom_get_process
     sweep_rate_set_process = PS_custom_set_process
-    sweep_rate_set_command = "A%d"
+    sweep_rate_set_command = "T%d"
diff --git a/pymeasure/instruments/velleman/__init__.py b/pymeasure/instruments/velleman/__init__.py
new file mode 100644
index 00000000..ad3b69a0
--- /dev/null
+++ b/pymeasure/instruments/velleman/__init__.py
@@ -0,0 +1,25 @@
+#
+# 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.
+#
+
+from .velleman_k8090 import VellemanK8090, VellemanK8090Switches
diff --git a/pymeasure/instruments/velleman/velleman_k8090.py b/pymeasure/instruments/velleman/velleman_k8090.py
new file mode 100644
index 00000000..8211aa92
--- /dev/null
+++ b/pymeasure/instruments/velleman/velleman_k8090.py
@@ -0,0 +1,268 @@
+#
+# 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.
+#
+
+from enum import IntFlag
+
+from pyvisa import VisaIOError
+from pymeasure.adapters import SerialAdapter, VISAAdapter
+from pymeasure.instruments import Instrument
+
+
+class VellemanK8090Switches(IntFlag):
+    """Use to identify switch channels."""
+
+    NONE = 0
+    CH1 = 1 << 0
+    CH2 = 1 << 1
+    CH3 = 1 << 2
+    CH4 = 1 << 3
+    CH5 = 1 << 4
+    CH6 = 1 << 5
+    CH7 = 1 << 6
+    CH8 = 1 << 7
+    ALL = CH1 | CH2 | CH3 | CH4 | CH5 | CH6 | CH7 | CH8
+
+
+def _parse_channels(channels) -> str:
+    """Convert array of channel numbers into mask if needed."""
+    if isinstance(channels, list):
+        mask = VellemanK8090Switches.NONE
+        for ch in channels:
+            mask |= 1 << (ch - 1)
+    else:
+        mask = channels
+
+    return hex(mask)
+
+
+def _get_process_status(items):
+    """Process the result of a 0x51 status message.
+
+    :param items: List of 4 integers: [CMD, MASK, Param1, Param2]
+    """
+    if len(items) < 4 or items[0] != 0x51:
+        return None, None, None
+
+    return [VellemanK8090Switches(it) for it in items[1:]]
+
+
+class VellemanK8090(Instrument):
+    """For usage with the K8090 relay board, by Velleman.
+
+    View the "K8090/VM8090 PROTOCOL MANUAL" for the serial command instructions.
+
+    The communication is done by serial USB. The IO settings are fixed:
+
+    ==================  ==================
+    Baud rate           19200
+    Data bits           8
+    Parity              None
+    Stop bits           1
+    Flow control        None
+    ==================  ==================
+
+    A short timeout is recommended, since the device is not consistent in giving status messages
+    and serial timeouts will occur also in normal operation.
+
+    Use the class like:
+
+    .. code-block:: python
+
+       from pymeasure.instruments.velleman import VellemanK8090, VellemanK8090Switches as Switches
+
+       instrument = VellemanK8090("ASRL1::INSTR")
+
+       # Get status update from device
+       last_on, curr_on, time_on = instrument.status
+
+       # Toggle a selection of channels on
+       instrument.switch_on = Switches.CH3 | Switches.CH4 | Switches.CH5
+
+    """
+
+    def __init__(self, adapter, name="Velleman K8090", timeout=100, **kwargs):
+        super().__init__(
+            adapter,
+            name=name,
+            asrl={"baud_rate": 19200},
+            write_termination="",
+            read_termination="",
+            timeout=timeout,
+            **kwargs,
+        )
+
+    BYTE_STX = 0x04
+    BYTE_ETX = 0x0F
+
+    version = Instrument.measurement(
+        "0x71",
+        """
+        Get firmware version, as (year - 2000, week). E.g. ``(10, 1)``
+        """,
+        cast=int,
+        get_process=lambda v: (v[2], v[3]) if len(v) > 3 and v[0] == 0x71 else None,
+    )
+
+    status = Instrument.measurement(
+        "0x18",
+        """
+        Get current relay status.
+        The reply has a different command byte than the request.
+
+        Three items (:class:`VellemanK8090Switches` flags) are returned:
+
+        * Previous state: the state of each relay before this event
+        * Current state: the state of each relay now
+        * Timer state: the state of each relay timer
+        """,
+        cast=int,
+        get_process=_get_process_status,
+    )
+
+    switch_on = Instrument.setting(
+        "0x11,%s",
+        """"
+        Switch on a set of channels. Other channels are unaffected.
+        Pass either a list or set of channel numbers (starting at 1), or pass a bitmask.
+
+        After switching this waits for a reply from the device. This is only send when
+        a relay actually toggles, otherwise expect a blocking time equal to the
+        communication timeout
+        If speed is important, avoid calling `switch_` unnecessarily.
+        """,
+        set_process=_parse_channels,
+        check_set_errors=True,
+    )
+
+    switch_off = Instrument.setting(
+        "0x12,%s",
+        """
+        Switch off a set of channels. See :attr:`switch_on` for more details.
+        """,
+        set_process=_parse_channels,
+        check_set_errors=True,
+    )
+
+    id = None  # No identification available
+
+    def _make_checksum(self, command, mask, param1, param2):
+        # The formula from the sheet requires twos-complement negation,
+        # this works
+        return 1 + 0xFF - ((self.BYTE_STX + command + mask + param1 + param2) & 0xFF)
+
+    def write(self, command, **kwargs):
+        """The write command specifically for the protocol of the K8090.
+
+        This overrides the method from the ``Instrument`` class.
+
+        Each packet to the device is 7 bytes:
+
+        STX (0x04) - CMD - MASK - PARAM1 - PARAM2 - CHK - ETX (0x0F)
+
+        Where `CHK` is checksum of the package.
+
+        :param command: String like "CMD[, MASK, PARAM1, PARAM2]" - only CMD is mandatory
+        :type command: str
+        """
+
+        # The device can give status updates when we don't expect it,
+        # drop anything from the buffer first
+        if isinstance(self.adapter, VISAAdapter):
+            self.adapter.flush_read_buffer()
+        elif isinstance(self.adapter, SerialAdapter):
+            # The SerialAdapter does not have `flush_read_buffer` implemented
+            self.adapter.connection.flush()
+
+        items_str = command.split(",")
+
+        items = [int(it, 16) for it in items_str]
+
+        cmd = items[0]
+        mask = items[1] if len(items) > 1 else 0
+        param1 = items[2] if len(items) > 2 else 0
+        param2 = items[3] if len(items) > 3 else 0
+
+        checksum = self._make_checksum(cmd, mask, param1, param2)
+
+        content = [
+            self.BYTE_STX,
+            cmd,
+            mask,
+            param1,
+            param2,
+            checksum,
+            self.BYTE_ETX,
+        ]
+
+        self.write_bytes(bytes(content))
+
+    def read(self, **kwargs):
+        """The read command specifically for the protocol of the K8090.
+
+        This overrides the method from the ``instrument`` class.
+
+        See :meth:`write`, replies from the machine use the same format.
+
+        A read will return a list of CMD, MASK, PARAM1 and PARAM2.
+        """
+        # A message is always 7 bytes
+        # (there is also a termination char, but since it is not exclusive it cannot be
+        # reliably used)
+        response = self.read_bytes(7)
+
+        if len(response) < 7:
+            raise ConnectionError(f"Incoming packet was {len(response)} bytes instead of 7")
+
+        # Only consider the most recent block
+        stx, command, mask, param1, param2, checksum, etx = list(response[-7:])
+
+        if stx != self.BYTE_STX or etx != self.BYTE_ETX:
+            raise ConnectionError(f"Received invalid start and stop bytes `{stx}` and `{etx}`")
+
+        if command == 0x00:
+            raise ConnectionError(f"Received invalid command byte `{command}`")
+
+        real_checksum = self._make_checksum(command, mask, param1, param2)
+        if real_checksum != checksum:
+            raise ConnectionError(
+                f"Packet checksum was not correct, got {hex(checksum)} "
+                f"instead of {hex(real_checksum)}"
+            )
+
+        values_str = [str(v) for v in [command, mask, param1, param2]]
+
+        return ",".join(values_str)
+
+    def check_errors(self):
+        """Called after each set command, normally to look for errors.
+
+        The K8090 replies with a status after a switch command, but
+        **only** after any switch actually changed. In order to guarantee
+        the buffer is empty, we attempt to read it fully here.
+        No actual error checking is done here!
+        """
+        try:
+            self.read()
+        except (VisaIOError, ConnectionError):
+            pass  # Ignore a timeout


Test patch:
diff --git a/tests/adapters/test_serial.py b/tests/adapters/test_serial.py
index d08970bd..789ac6fd 100644
--- a/tests/adapters/test_serial.py
+++ b/tests/adapters/test_serial.py
@@ -73,6 +73,12 @@ def test_read_bytes_unlimited(adapter):
     assert adapter.read_bytes(-1) == b"basd\x02\nfasdf\n"
 
 
+def test_read_bytes_unlimited_long(adapter):
+    """Test whether all bytes are returned when a lot of data is sent."""
+    adapter.write_bytes(b"abcde" * 50)
+    assert adapter.read_bytes(-1) == b"abcde" * 50
+
+
 @pytest.mark.parametrize("count", (-1, 8))
 def test_read_bytes_break_on_termchar(adapter, count):
     adapter.read_termination = "\n"
diff --git a/tests/adapters/test_serial_with_loopback.py b/tests/adapters/test_serial_with_loopback.py
new file mode 100644
index 00000000..5f7fa822
--- /dev/null
+++ b/tests/adapters/test_serial_with_loopback.py
@@ -0,0 +1,68 @@
+import pytest
+
+from serial import Serial
+from time import time
+from pymeasure.adapters import SerialAdapter
+
+
+class TestSerialLoopback:
+    """Test serial adapter with two real but looped COM ports.
+
+    A pair of COM ports that are looped will read the written data from the other.
+    This looping needs to be done outside this script. One suggestion is using the application
+    `com0com <https://com0com.sourceforge.net/>`_.
+    Alternatively two UART adapters could be used, that have their RX/TX physically connected.
+
+    Call PyTest with the argument``--device-address="COM1,COM2"`` to specify the serial addresses,
+    minding the comma for the separation of the two ports.
+    """
+
+    ADAPTER_TIMEOUT = 1.0  # Seconds
+
+    @pytest.fixture()
+    def adapter(self, connected_device_address):
+        device = connected_device_address.split(",")[0]
+        return SerialAdapter(
+            device,
+            baudrate=19200,
+            timeout=self.ADAPTER_TIMEOUT,
+            read_termination=chr(0x0F),
+        )
+
+    @pytest.fixture()
+    def loopback(self, connected_device_address):
+        """See `adapter()`."""
+        device = connected_device_address.split(",")[1]
+        return Serial(device, baudrate=19200)
+
+    def test_read(self, adapter, loopback):
+        """Regular read with fixed number of bytes."""
+        loopback.write(b"abc")
+        result = adapter.read_bytes(3)
+
+        assert len(result) == 3
+
+    @pytest.mark.parametrize("data", [
+        b"a",                               # Short data
+        b"aaaaabbbbbccccceeeee" * 50,       # A lot data
+        b"a" * 256,                         # Exactly one chunk size
+        b"aaaaabbbbb\nccccceeeee" * 50,     # With a newline (should be ignored)
+    ])
+    def test_read_all(self, adapter, loopback, data):
+        """Read with undefined number of bytes - also confirm timeout duration is correct."""
+        loopback.write(data)
+        start = time()
+        result = adapter.read_bytes(-1)
+        elapsed = time() - start
+
+        assert result == data
+        assert self.ADAPTER_TIMEOUT == pytest.approx(elapsed, abs=0.1)
+
+    @pytest.mark.parametrize("chunk", [1, 256, 10000])
+    def test_read_varied_chunk_size(self, adapter, loopback, chunk):
+        """Read with undefined number of bytes with non-default chunk size."""
+        data = b"abcde" * 10
+        loopback.write(data)
+        result = adapter.read_bytes(-1)
+
+        assert result == data
diff --git a/tests/instruments/oxfordinstruments/test_base_instrument.py b/tests/instruments/oxfordinstruments/test_base_instrument.py
new file mode 100644
index 00000000..2fb8438a
--- /dev/null
+++ b/tests/instruments/oxfordinstruments/test_base_instrument.py
@@ -0,0 +1,47 @@
+#
+# 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.test import expected_protocol
+
+
+from pymeasure.instruments.oxfordinstruments.base import OxfordInstrumentsBase, OxfordVISAError
+
+
+def test_wrong_response():
+    with expected_protocol(OxfordInstrumentsBase,
+                           [("A", "B"), (None, "")],
+                           max_attempts=1,
+                           ) as inst:
+        with pytest.raises(OxfordVISAError):
+            inst.ask("A")
+
+
+def test_write_not_understood_command():
+    with expected_protocol(OxfordInstrumentsBase,
+                           [("A", "?B")],
+                           ) as inst:
+        with pytest.raises(OxfordVISAError):
+            inst.write("A")
diff --git a/tests/instruments/oxfordinstruments/test_ips120_10.py b/tests/instruments/oxfordinstruments/test_ips120_10.py
new file mode 100644
index 00000000..985e032e
--- /dev/null
+++ b/tests/instruments/oxfordinstruments/test_ips120_10.py
@@ -0,0 +1,77 @@
+#
+# 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.
+#
+
+from pymeasure.test import expected_protocol
+
+
+from pymeasure.instruments.oxfordinstruments.ips120_10 import IPS120_10
+
+
+def test_version():
+    with expected_protocol(IPS120_10,
+                           [("V", "IPS120-10 Version 3.04 @ Oxford Instruments 1996")]
+                           ) as inst:
+        assert inst.version == "IPS120-10 Version 3.04 @ Oxford Instruments 1996"
+
+
+def test_activity_getter():
+    with expected_protocol(IPS120_10,
+                           [("X", "X00A0C0M00P00")]
+                           ) as inst:
+        assert inst.activity == "hold"
+
+
+def test_activity_setter():
+    with expected_protocol(IPS120_10,
+                           [("A0", "A")]
+                           ) as inst:
+        inst.activity = "hold"
+
+
+def test_current_setpoint_getter():
+    with expected_protocol(IPS120_10,
+                           [("R0", "R+1.3")]
+                           ) as inst:
+        assert inst.current_setpoint == 1.3
+
+
+def test_current_setpoint_setter():
+    with expected_protocol(IPS120_10,
+                           [("I1.300000", "I")]
+                           ) as inst:
+        inst.current_setpoint = 1.3
+
+
+def test_control_mode_getter():
+    with expected_protocol(IPS120_10,
+                           [("X", "X00A0C1M00P00")]
+                           ) as inst:
+        assert inst.control_mode == "RL"
+
+
+def test_control_mode_setter():
+    with expected_protocol(IPS120_10,
+                           [("C1", "C")]
+                           ) as inst:
+        inst.control_mode = "RL"
diff --git a/tests/instruments/oxfordinstruments/test_ps120_10.py b/tests/instruments/oxfordinstruments/test_ps120_10.py
new file mode 100644
index 00000000..64d4bf02
--- /dev/null
+++ b/tests/instruments/oxfordinstruments/test_ps120_10.py
@@ -0,0 +1,84 @@
+#
+# 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.
+#
+
+from pymeasure.test import expected_protocol
+
+
+from pymeasure.instruments.oxfordinstruments.ps120_10 import PS120_10
+
+
+def test_version():
+    with expected_protocol(PS120_10,
+                           [("V", "IPS120-10 Version 3.04 @ Oxford Instruments 1996")]
+                           ) as inst:
+        assert inst.version == "IPS120-10 Version 3.04 @ Oxford Instruments 1996"
+
+
+def test_activity_getter():
+    with expected_protocol(PS120_10,
+                           [("X", "X00A0C0M00P00")]
+                           ) as inst:
+        assert inst.activity == "hold"
+
+
+def test_activity_setter():
+    with expected_protocol(PS120_10,
+                           [("A0", "A")]
+                           ) as inst:
+        inst.activity = "hold"
+
+
+def test_current_setpoint_getter():
+    with expected_protocol(PS120_10,
+                           [("R0", "R+130")]
+                           ) as inst:
+        assert inst.current_setpoint == 1.3
+
+
+def test_current_setpoint_setter():
+    with expected_protocol(PS120_10,
+                           [("I130", "I")]
+                           ) as inst:
+        inst.current_setpoint = 1.3
+
+
+def test_control_mode_getter():
+    with expected_protocol(PS120_10,
+                           [("X", "X00A0C1M00P00")]
+                           ) as inst:
+        assert inst.control_mode == "RL"
+
+
+def test_control_mode_setter():
+    with expected_protocol(PS120_10,
+                           [("C1", "C")]
+                           ) as inst:
+        inst.control_mode = "RL"
+
+
+def test_field_setpoint():
+    with expected_protocol(PS120_10,
+                           [("R8", "R+00100")],
+                           ) as inst:
+        assert inst.field_setpoint == 1.0
diff --git a/tests/instruments/velleman/test_velleman_k8090.py b/tests/instruments/velleman/test_velleman_k8090.py
new file mode 100644
index 00000000..d6394a62
--- /dev/null
+++ b/tests/instruments/velleman/test_velleman_k8090.py
@@ -0,0 +1,112 @@
+#
+# 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.
+#
+
+
+"""
+Test the instrument class for the Velleman F8090.
+
+This is not an integration test! This is software-only.
+"""
+import pytest
+
+from pymeasure.test import expected_protocol
+from pymeasure.instruments.velleman import VellemanK8090, VellemanK8090Switches as Switches
+
+
+def test_version():
+    with expected_protocol(
+        VellemanK8090,
+        [
+            (
+                bytearray.fromhex("04 71 00 00 00 8B 0F"),
+                bytearray.fromhex("04 71 FF 0A 01 81 0F"),
+            )
+        ],
+    ) as inst:
+        assert inst.version == (10, 1)
+
+
+@pytest.mark.parametrize("reply,msg", [
+    (b"0", "Incoming packet was 1 bytes instead of 7"),
+    (bytearray.fromhex("04 71 FF 0A 01 82 0F"), "Packet checksum was not correct"),
+    (bytearray.fromhex("04 71 FF 0A 01 81 00"), "Received invalid start and stop bytes"),
+])
+def test_version_bad_reply_short(reply, msg):
+    with expected_protocol(
+        VellemanK8090,
+        [
+            (
+                bytearray.fromhex("04 71 00 00 00 8B 0F"),
+                reply,
+            )
+        ],
+    ) as inst:
+        with pytest.raises(ConnectionError, match=msg):
+            _ = inst.version
+
+
+def test_status():
+    with expected_protocol(
+        VellemanK8090,
+        [
+            (
+                bytearray.fromhex("04 18 00 00 00 E4 0F"),
+                bytearray.fromhex("04 51 01 15 80 15 0F"),
+            )
+        ],
+    ) as inst:
+        last_on, curr_on, time_on = inst.status
+
+        assert last_on == Switches.CH1
+        assert curr_on == Switches.CH1 | Switches.CH3 | Switches.CH5
+        assert time_on == Switches.CH8
+
+
+def test_switch_on():
+    with expected_protocol(
+        VellemanK8090,
+        [
+            (
+                bytearray.fromhex("04 11 05 00 00 E6 0F"),
+                bytearray.fromhex("04 51 01 05 80 25 0F"),
+            )
+        ]
+        * 2,
+    ) as inst:
+        # Test both signatures
+        inst.switch_on = Switches.CH1 | Switches.CH3
+        inst.switch_on = [1, 3]
+
+
+def test_switch_off():
+    with expected_protocol(
+        VellemanK8090,
+        [
+            (
+                bytearray.fromhex("04 12 FF 00 00 EB 0F"),
+                bytearray.fromhex("04 51 01 00 80 2A 0F"),
+            )
+        ],
+    ) as inst:
+        inst.switch_off = Switches.ALL
diff --git a/tests/instruments/velleman/test_velleman_k8090_with_device.py b/tests/instruments/velleman/test_velleman_k8090_with_device.py
new file mode 100644
index 00000000..af8880a4
--- /dev/null
+++ b/tests/instruments/velleman/test_velleman_k8090_with_device.py
@@ -0,0 +1,92 @@
+#
+# 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.
+#
+
+
+"""
+Test the instrument class for the Velleman F8090.
+
+This tests the instrument class with the real device connected.
+"""
+
+import pytest
+from time import sleep
+
+from pymeasure.adapters import SerialAdapter
+from pymeasure.instruments.velleman import VellemanK8090, VellemanK8090Switches as Switches
+
+
+@pytest.fixture()
+def instrument(connected_device_address):
+    """Get instrument object.
+
+    Run like ``--device-address="ASRL1::INSTR"`` to use the visa adapter, and
+    like ``--device-address="COM1"`` to use the serial adapter.
+    """
+    if "ASRL" in connected_device_address:
+        return VellemanK8090(connected_device_address, timeout=500)
+    else:
+        adapter = SerialAdapter(
+            connected_device_address, baudrate=19200, timeout=0.5, read_termination=chr(0x0F)
+        )
+        return VellemanK8090(adapter)
+
+
+def test_version(instrument):
+    ver = instrument.version
+
+    assert ver is not None and isinstance(ver, tuple)
+
+
+def test_status(instrument):
+    last_on, curr_on, time_on = instrument.status
+
+    assert isinstance(curr_on, Switches)
+
+
+def test_switch_on_off(instrument):
+    """Test switch on-off together.
+
+    This tests the physical switches and the status feedback.
+    You must also hear the real relays clicking, and verify the
+    LEDs 1 and 3 alone light up.
+    """
+    instrument.switch_off = Switches.ALL
+    sleep(0.5)
+
+    instrument.switch_on = Switches.CH1 | Switches.CH3
+    sleep(0.5)
+
+    _, curr_on_1, _ = instrument.status
+
+    instrument.switch_off = Switches.ALL
+    sleep(0.5)
+
+    _, curr_on_2, _ = instrument.status
+
+    assert curr_on_1 == Switches.CH1 | Switches.CH3
+    assert curr_on_2 == Switches.NONE
+
+    # Test another pointless off switch, to test a lack of confirmation
+    instrument.switch_off = Switches.ALL
+    sleep(0.5)