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:
A question: when a relay is switched, the board will send a status update (consisting of the 8 relay modes, previous modes and the timer settings).

There is for example the `switch_on` command, which will flip a set of channel on. If any of these now changed, the board will reply with this status. But it won't reply if the relays didn't actually switch.

How could I best incorporate this? I was planning to make the `switch_on` a `setting`, since it's write-only. The reply by the board is then ignored, which is no big deal, but this reply will stay in the serial buffer and by read later instead, of an expected response.  

The problem illustrated (in pseudo-commands):

```
Write: "id?" > Buffer: "k8090"
Read: "k8090" > Buffer: ""

Write: "switch_on 5" > Buffer: "on:5"

Write: "id?" > Buffer: "on:5 k8090"
Read: "on: 5" > Buffer: "k8090"
```

How could I solve this?

Resolving patch:
diff --git a/pymeasure/instruments/__init__.py b/pymeasure/instruments/__init__.py
--- a/pymeasure/instruments/__init__.py
+++ b/pymeasure/instruments/__init__.py
@@ -71,4 +71,5 @@
 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
--- /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
--- /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
--- /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
--- /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)