Repository: pymeasure/pymeasure

Issue:
Add support for the Velleman K8090 relay board
I would like to add support for the [Velleman K8090](https://www.velleman.eu/products/view/?id=377966), an 8-channel relay board that communicates using serial.

Like my other issue, I already have old code that I would like to port. This issue is mostly to indicate my work-in-progress.

Please let me know if this device is not popular enough and there is no interest to merge it, which I could understand.

Discussion:
RobertoRoos: Thank you for the feedback @bmoneke , this is a cool project work on.

Let me know if you'd like more changes or a rebase.

I should also point out not all options of the K8090 are covered yet. But I didn't really want to spend time on the features I don't need at this moment.
bmoneke: > I should also point out not all options of the K8090 are covered yet. But I didn't really want to spend time on the features I don't need at this moment.

As long as an instrument is usable (most important features covered), it's fine. We all are volunteers with limited time.

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/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/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/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/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)