WavePal

Python interface for Wave Pal, a waveform player for Pulse Pal 3.

Wave Pal is alternative firmware for Pulse Pal 3 hardware. It stores one sampled waveform per output channel on the device's microSD card, and plays it when the channel is triggered: by a TTL pulse on a trigger channel, from software, or from the thumb joystick. Waveforms can be up to a million samples long, and are played at up to 100 kHz.

Everything is accessed through WavePalDevice. Import it, connect to the device's serial port, load waveforms, and trigger, e.g.

import numpy as np
from WavePal import WavePalDevice

with WavePalDevice("COM3") as W:
    W.sampling_rate = 50000                         # Hz, all channels
    t = np.arange(50000) / 50000                    # 1 second
    W.load_waveform(1, 5 * np.sin(2 * np.pi * 10 * t))
    W.play(1)

The device needs Wave Pal firmware, which is in /Firmware/WavePal in the Pulse Pal repository. Its USB protocol is documented in PROTOCOL.md.

Channel settings

Settings that apply to one output channel, such as WavePalDevice.loop_mode, are lists indexed by channel number: index 0 is unused and holds None, and indices 1 to 4 hold the settings of output channels 1-4, as in PulsePal.PulsePalDevice. Setting an element or a slice programs the device at once. Assigning a whole list sets all four channels, and a single value sets them all to that value:

W.loop_mode[2] = True          # channel 2 only
W.loop_duration[1:5] = [1, 2, 3, 4]
W.trigger_mode = "Toggle"      # all four channels

Units

Voltages are in volts, within WavePalDevice.output_range. Times are in seconds, and the sampling rate is in Hz.

License

This file is part of the Sanworks PulsePal repository. Copyright (C) Sanworks LLC, Rochester, New York, USA

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.

This program is distributed WITHOUT ANY WARRANTY and without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.

   1"""
   2Python interface for Wave Pal, a waveform player for Pulse Pal 3.
   3
   4Wave Pal is alternative firmware for Pulse Pal 3 hardware. It stores one
   5sampled waveform per output channel on the device's microSD card, and
   6plays it when the channel is triggered: by a TTL pulse on a trigger
   7channel, from software, or from the thumb joystick. Waveforms can be up
   8to a million samples long, and are played at up to 100 kHz.
   9
  10Everything is accessed through `WavePalDevice`. Import it, connect to
  11the device's serial port, load waveforms, and trigger, e.g.
  12
  13```python
  14import numpy as np
  15from WavePal import WavePalDevice
  16
  17with WavePalDevice("COM3") as W:
  18    W.sampling_rate = 50000                         # Hz, all channels
  19    t = np.arange(50000) / 50000                    # 1 second
  20    W.load_waveform(1, 5 * np.sin(2 * np.pi * 10 * t))
  21    W.play(1)
  22```
  23
  24The device needs Wave Pal firmware, which is in
  25[/Firmware/WavePal](https://github.com/sanworks/PulsePal/tree/develop/Firmware/WavePal) in the
  26Pulse Pal repository. Its USB protocol is documented in
  27[PROTOCOL.md](https://github.com/sanworks/PulsePal/blob/develop/Firmware/WavePal/PROTOCOL.md).
  28
  29## Channel settings
  30
  31Settings that apply to one output channel, such as
  32`WavePalDevice.loop_mode`, are lists indexed by channel number: index 0
  33is unused and holds `None`, and indices 1 to 4 hold the settings of
  34output channels 1-4, as in `PulsePal.PulsePalDevice`. Setting an element
  35or a slice programs the device at once. Assigning a whole list sets all
  36four channels, and a single value sets them all to that value:
  37
  38```python
  39W.loop_mode[2] = True          # channel 2 only
  40W.loop_duration[1:5] = [1, 2, 3, 4]
  41W.trigger_mode = "Toggle"      # all four channels
  42```
  43
  44## Units
  45
  46Voltages are in volts, within `WavePalDevice.output_range`. Times are in
  47seconds, and the sampling rate is in Hz.
  48
  49## License
  50
  51This file is part of the Sanworks PulsePal repository.
  52Copyright (C) Sanworks LLC, Rochester, New York, USA
  53
  54This program is free software: you can redistribute it and/or modify
  55it under the terms of the GNU General Public License as published by
  56the Free Software Foundation, version 3.
  57
  58This program is distributed WITHOUT ANY WARRANTY and without even the
  59implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  60See the GNU General Public License for more details.
  61
  62You should have received a copy of the GNU General Public License
  63along with this program. If not, see <http://www.gnu.org/licenses/>.
  64"""
  65
  66from dataclasses import dataclass
  67import math
  68import numbers
  69import struct
  70import weakref
  71
  72import numpy as np
  73import serial
  74import serial.tools.list_ports
  75
  76__all__ = ["WavePalDevice", "DeviceInfo", "DeviceStatus", "WavePalError"]
  77__docformat__ = "google"
  78
  79# Output ranges in order of their range index on the device, with their
  80# limits in volts. See "Output ranges" in /Firmware/WavePal/PROTOCOL.md.
  81OUTPUT_RANGES = {
  82    "0V:5V": (0.0, 5.0),
  83    "0V:10V": (0.0, 10.0),
  84    "-5V:5V": (-5.0, 5.0),
  85    "-10V:10V": (-10.0, 10.0),
  86}
  87
  88# Trigger modes in order of their code on the device
  89TRIGGER_MODES = ("Normal", "Master", "Toggle", "Gated")
  90
  91
  92class WavePalError(Exception):
  93    """Raised when Wave Pal communication or configuration fails.
  94
  95    This covers serial reads that time out, short serial writes,
  96    commands the device rejects, and values that are out of range, such
  97    as a voltage outside the output range or a sampling rate the device
  98    cannot play.
  99    """
 100
 101
 102@dataclass
 103class DeviceInfo:
 104    """Properties of the connected Wave Pal.
 105
 106    Populated when `WavePalDevice` connects, and available as
 107    `WavePalDevice.info`.
 108    """
 109
 110    firmware_version: int = None
 111    """Wave Pal firmware version running on the device."""
 112
 113    hardware_version: int = None
 114    """Pulse Pal hardware version, e.g. `3`."""
 115
 116    n_channels: int = None
 117    """Number of output channels."""
 118
 119    max_samples: int = None
 120    """Maximum number of samples in one waveform."""
 121
 122    max_sampling_rate: int = None
 123    """Highest sampling rate, in Hz."""
 124
 125    buffer_samples: int = None
 126    """Samples per playback buffer.
 127
 128    The first `buffer_samples` samples of each waveform are kept in the
 129    device's RAM, so that playback starts at once. A waveform no longer
 130    than this plays without reading the microSD card.
 131    """
 132
 133    sample_clock_hz: int = None
 134    """Clock that the sample rate is divided from, in Hz.
 135
 136    See `WavePalDevice.actual_sampling_rate`.
 137    """
 138
 139    output_ranges: tuple = tuple(OUTPUT_RANGES)
 140    """Names of the output ranges, accepted by
 141    `WavePalDevice.output_range`."""
 142
 143    trigger_modes: tuple = TRIGGER_MODES
 144    """Names of the trigger modes, accepted by
 145    `WavePalDevice.trigger_mode`."""
 146
 147
 148@dataclass
 149class DeviceStatus:
 150    """A snapshot of the device's playback state, from
 151    `WavePalDevice.status`."""
 152
 153    playing: list
 154    """Numbers of the output channels playing a waveform, e.g. `[1, 3]`."""
 155
 156    samples_loaded: list
 157    """Samples in each channel's waveform, `0` if it has none.
 158
 159    Indexed by channel number, with index 0 unused. This is what the
 160    device holds, which can include waveforms loaded by an earlier
 161    connection.
 162    """
 163
 164    underruns: list
 165    """Underruns on each channel since the device started, indexed by
 166    channel number.
 167
 168    An underrun is a block of samples that was not read from the microSD
 169    card by the time it was due. The output then holds its last value
 170    until the block arrives. See "Storage and buffering" in the
 171    [Wave Pal protocol](https://github.com/sanworks/PulsePal/blob/develop/Firmware/WavePal/PROTOCOL.md#storage-and-buffering).
 172    """
 173
 174    longest_interrupt_us: float
 175    """Longest run of the device's playback interrupt since the previous
 176    call to `WavePalDevice.status`, in microseconds.
 177
 178    It must stay below the sample period, `1e6 / sampling_rate`.
 179    """
 180
 181
 182class ChannelSettings(list):
 183    """One setting per output channel, indexed by channel number.
 184
 185    Index 0 is unused and holds `None`, so `settings[2]` belongs to output
 186    channel 2. Setting an element or a slice programs the device at once;
 187    if the device refuses the new values, the list is left unchanged.
 188    The list always holds five elements, so methods that would change
 189    its length raise `TypeError`. `list(settings)` or `copy.copy` gives a
 190    plain list, detached from the device.
 191    """
 192
 193    def __init__(self, name, values, device, apply_method):
 194        super().__init__([None, *values])
 195        self._name = name
 196        # A weak reference, so that the device and its settings do not form
 197        # a reference cycle: deleting the device then closes its port at once
 198        self._device = weakref.ref(device)
 199        self._apply_method = apply_method
 200
 201    def __setitem__(self, index, value):
 202        values = list(self)
 203        values[index] = value
 204        if len(values) != 5:
 205            raise WavePalError(
 206                f"{self._name} holds one value per output channel, at "
 207                "indices 1-4. A slice assignment must keep its length."
 208            )
 209        if values[0] is not None:
 210            raise WavePalError(
 211                f"{self._name}[0] is unused: output channels are numbered "
 212                "1-4."
 213            )
 214        self._set_all(values[1:])
 215
 216    def _assign(self, values):
 217        """Set all four channels from a single value, 4 values, or a
 218        5-element list with index 0 unused."""
 219        if isinstance(values, (str, bytes)) or not _is_iterable(values):
 220            values = [values] * 4
 221        else:
 222            values = list(values)
 223            if len(values) == 5:
 224                values = values[1:]
 225            elif len(values) != 4:
 226                raise WavePalError(
 227                    f"{self._name} needs one value for all channels, or "
 228                    f"one value per output channel 1-4. Received "
 229                    f"{len(values)} values."
 230                )
 231        self._set_all(values)
 232
 233    def _set_all(self, values):
 234        device = self._device()
 235        if device is None:
 236            raise WavePalError(f"The device that owns {self._name} is gone.")
 237        normalized = getattr(device, self._apply_method)(values)
 238        super().__setitem__(slice(0, 5), [None, *normalized])
 239
 240    def __reduce__(self):
 241        return (list, (list(self),))
 242
 243    def _refuse(self, *args, **kwargs):
 244        raise TypeError(
 245            f"{self._name} holds exactly one value per output channel. "
 246            "Set its elements instead."
 247        )
 248
 249    append = extend = insert = pop = remove = clear = _refuse
 250    sort = reverse = __delitem__ = __iadd__ = __imul__ = _refuse
 251
 252
 253def _is_iterable(value):
 254    try:
 255        iter(value)
 256    except TypeError:
 257        return False
 258    return True
 259
 260
 261class WavePalDevice:
 262    """A class to control a Wave Pal on a USB serial port.
 263
 264    Creating an instance opens the serial port, checks that the device
 265    runs Wave Pal firmware, reads its properties into
 266    `WavePalDevice.info`, shows "PYTHON Connected" on the device's
 267    screen, stops any playback and programs the default settings (see
 268    `WavePalDevice.set_defaults`).
 269
 270    ```python
 271    from WavePal import WavePalDevice
 272
 273    W = WavePalDevice("COM3")
 274    W.load_waveform(1, [0, 1, 2, 3, 4, 5, 0])
 275    W.play(1)
 276    W.close()
 277    ```
 278
 279    Replace "COM3" with the device's USB serial port name, which
 280    `WavePalDevice.serialportlist` lists. `WavePalDevice` is also a
 281    context manager, which closes the port on exit.
 282
 283    Closing the connection puts the device's own name back on its
 284    screen, and leaves everything else as it is: playback continues, and
 285    TTL triggers keep playing the loaded waveforms.
 286    """
 287
 288    port: "serial.Serial"
 289    """The open `serial.Serial` port connected to the device."""
 290
 291    info: DeviceInfo
 292    """Properties of the connected device. See `DeviceInfo`."""
 293
 294    _CURRENT_FIRMWARE_VERSION = 1
 295
 296    _OP_MENU_BYTE = 213
 297    _OP_HANDSHAKE = 72
 298    _OP_DISCONNECT = 81
 299    _OP_SET_CLIENT_NAME = 89
 300    _OP_HARDWARE_INFO = ord("N")
 301    _OP_SET_SAMPLING_RATE = ord("S")
 302    _OP_SET_OUTPUT_RANGE = ord("R")
 303    _OP_LOAD_WAVEFORM = ord("L")
 304    _OP_PLAY = ord("P")
 305    _OP_STOP = ord("X")
 306    _OP_SET_FIXED_VOLTAGE = ord("!")
 307    _OP_SET_LOOP_MODE = ord("O")
 308    _OP_SET_LOOP_DURATION = ord("D")
 309    _OP_SET_TRIGGER_MODE = ord("T")
 310    _OP_SET_TRIGGER_LINKS = ord("I")
 311    _OP_GET_STATUS = ord("G")
 312    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 313
 314    _WAVE_PAL_HANDSHAKE_REPLY = 87  # 'W'
 315    _PULSE_PAL_HANDSHAKE_REPLY = 75  # 'K': the device runs Pulse Pal firmware
 316    _HARDWARE_INFO_FORMAT = "<BBIIII"
 317    _STATUS_FORMAT = "<B4I4II"
 318    _DAC_BITMAX = 65535
 319    _UINT32_MAX = 2**32 - 1
 320    _ALL_CHANNELS = 0x0F
 321
 322    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 323        """Open a connection to a Wave Pal.
 324
 325        Args:
 326            port_name: USB serial port of the device, such as `COM3` on
 327                Windows or `/dev/ttyACM0` on Linux.
 328            baud_rate: Serial baud rate. USB serial ignores it.
 329            timeout: Serial read timeout, in seconds. Loading a long
 330                waveform onto a slow microSD card can take a few seconds.
 331
 332        Raises:
 333            WavePalError: If the device does not reply to the handshake,
 334                runs Pulse Pal firmware, or runs Wave Pal firmware newer
 335                than this module supports.
 336            serial.SerialException: If the serial port cannot be opened.
 337        """
 338        self._closed = True
 339        self.info = DeviceInfo()
 340        self._sampling_rate = None
 341        self._output_range = None
 342        self._waveforms = [None] * 5
 343        self._loop_mode = ChannelSettings(
 344            "loop_mode", [False] * 4, self, "_apply_loop_mode")
 345        self._loop_duration = ChannelSettings(
 346            "loop_duration", [0.0] * 4, self, "_apply_loop_duration")
 347        self._trigger_mode = ChannelSettings(
 348            "trigger_mode", ["Normal"] * 4, self, "_apply_trigger_mode")
 349        self._link_trigger_channel1 = ChannelSettings(
 350            "link_trigger_channel1", [True] * 4, self,
 351            "_apply_trigger_channel1_links")
 352        self._link_trigger_channel2 = ChannelSettings(
 353            "link_trigger_channel2", [False] * 4, self,
 354            "_apply_trigger_channel2_links")
 355
 356        self.port = serial.Serial(
 357            port_name,
 358            baud_rate,
 359            timeout=timeout,
 360            rtscts=True,
 361        )
 362        self._closed = False
 363        try:
 364            # Discard anything left in the buffer by an earlier session
 365            self.port.reset_input_buffer()
 366            self._handshake()
 367            self._read_hardware_info()
 368            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 369            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 370            self.stop()
 371            self.set_defaults()
 372        except BaseException:
 373            # Op 81 means something else to other devices, so it is sent
 374            # only once the device has identified itself as a Wave Pal
 375            self.close(
 376                send_disconnect=self.info.firmware_version is not None)
 377            raise
 378
 379    @staticmethod
 380    def serialportlist(ports_to_list="available"):
 381        """Return the names of the USB serial ports on this computer.
 382
 383        Called on the class, without connecting to a device, to find the
 384        port name to pass to `WavePalDevice`.
 385
 386        Args:
 387            ports_to_list: `available` to list only the ports that are
 388                not already in use, or `all` to list every USB serial
 389                port. Not case sensitive.
 390
 391        Returns:
 392            Sorted list of port names, such as `["COM3", "COM7"]`.
 393
 394        Raises:
 395            WavePalError: If `ports_to_list` is not `available` or `all`.
 396        """
 397        mode = str(ports_to_list).lower()
 398        if mode not in ("available", "all"):
 399            raise WavePalError(
 400                f"Unknown port list type: {ports_to_list}. "
 401                "Use 'available' or 'all'."
 402            )
 403        port_names = []
 404        for port_info in serial.tools.list_ports.comports():
 405            is_usb = port_info.vid is not None or "USB" in (
 406                port_info.hwid or ""
 407            ).upper()
 408            if not is_usb:
 409                continue
 410            if mode == "available" and not WavePalDevice._port_is_free(
 411                port_info.device
 412            ):
 413                continue
 414            port_names.append(port_info.device)
 415        return sorted(port_names)
 416
 417    @staticmethod
 418    def _port_is_free(port_name):
 419        """Return True if the port is not already open in another program."""
 420        port = serial.Serial()
 421        port.port = port_name
 422        # Leaving the control lines low avoids resetting boards that
 423        # reset on DTR while the port is probed.
 424        port.dtr = False
 425        port.rts = False
 426        try:
 427            port.open()
 428        except (serial.SerialException, OSError):
 429            return False
 430        port.close()
 431        return True
 432
 433    # ------------------------------------------------------------------
 434    # Settings
 435    # ------------------------------------------------------------------
 436
 437    def set_defaults(self):
 438        """Program the default settings on the device.
 439
 440        The defaults are a 10 kHz sampling rate, the -10 V to 10 V output
 441        range, loop mode off with loop durations of 0, normal trigger
 442        mode, and all output channels linked to trigger channel 1 and not
 443        to trigger channel 2. They match the settings the device starts
 444        with.
 445
 446        Loaded waveforms are kept, and loaded again if the output range
 447        changes (see `WavePalDevice.output_range`).
 448
 449        Raises:
 450            WavePalError: If a loaded waveform does not fit the default
 451                output range, -10 V to 10 V.
 452        """
 453        self.sampling_rate = 10000
 454        self.output_range = "-10V:10V"
 455        self.loop_mode = False
 456        self.loop_duration = 0
 457        self.trigger_mode = "Normal"
 458        self._set_trigger_links([True] * 4, [False] * 4)
 459
 460    @property
 461    def sampling_rate(self):
 462        """Sampling rate of all output channels, in Hz.
 463
 464        A whole number from 1 to `DeviceInfo.max_sampling_rate`. It can be
 465        changed during playback. The rate played can differ slightly from
 466        the rate set: see `WavePalDevice.actual_sampling_rate`.
 467        """
 468        return self._sampling_rate
 469
 470    @sampling_rate.setter
 471    def sampling_rate(self, rate):
 472        try:
 473            rate_hz = int(rate)
 474            is_whole = rate_hz == rate and not isinstance(rate, bool)
 475        except (TypeError, ValueError, OverflowError):
 476            is_whole = False
 477        if not is_whole or not 1 <= rate_hz <= self.info.max_sampling_rate:
 478            raise WavePalError(
 479                "sampling_rate must be a whole number of Hz from 1 to "
 480                f"{self.info.max_sampling_rate}. Received {rate!r}."
 481            )
 482        # Loop durations are sent in samples, so check that they still fit
 483        # before anything is changed
 484        loop_samples = None
 485        if self._sampling_rate is not None:
 486            loop_samples = self._durations_to_samples(
 487                self._loop_duration[1:], rate_hz)
 488        self._write_command(self._OP_SET_SAMPLING_RATE,
 489                            struct.pack("<I", rate_hz))
 490        self._read_ack("setting sampling_rate")
 491        self._sampling_rate = rate_hz
 492        if loop_samples is not None:
 493            self._send_loop_duration_samples(loop_samples)
 494
 495    @property
 496    def actual_sampling_rate(self):
 497        """The sampling rate the device plays, in Hz.
 498
 499        The device divides `DeviceInfo.sample_clock_hz` (24 MHz) by a
 500        whole number, so the rate played is the nearest one of those to
 501        `WavePalDevice.sampling_rate`. For example, 44100 Hz plays at
 502        44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz,
 503        25 kHz or 100 kHz, play exactly.
 504        """
 505        return self._actual_rate(self._sampling_rate)
 506
 507    @property
 508    def output_range(self):
 509        """Voltage range of all output channels.
 510
 511        One of `DeviceInfo.output_ranges`: `"0V:5V"`, `"0V:10V"`,
 512        `"-5V:5V"` or `"-10V:10V"`. The smallest range that fits the
 513        waveforms gives the finest voltage steps.
 514
 515        Changing the range stops playback, sets all outputs to 0 V, and
 516        loads the waveforms again, encoded for the new range. It raises
 517        an error, and changes nothing, if a loaded waveform does not fit
 518        the new range.
 519        """
 520        return self._output_range
 521
 522    @output_range.setter
 523    def output_range(self, range_name):
 524        name = self._range_name(range_name)
 525        low, high = OUTPUT_RANGES[name]
 526        for channel in range(1, 5):
 527            waveform = self._waveforms[channel]
 528            if waveform is not None and (
 529                    waveform.min() < low or waveform.max() > high):
 530                raise WavePalError(
 531                    f"The waveform on channel {channel} spans "
 532                    f"{waveform.min():g} V to {waveform.max():g} V, which "
 533                    f"does not fit the {name} range. Load a new waveform "
 534                    "first, or choose a wider range."
 535                )
 536        range_index = list(OUTPUT_RANGES).index(name)
 537        self._write_command(self._OP_SET_OUTPUT_RANGE, bytes([range_index]))
 538        self._read_ack("setting output_range")
 539        changed = name != self._output_range
 540        self._output_range = name
 541        if changed:
 542            # The device unloads the waveforms when the range changes,
 543            # because their samples encode voltages in the old range
 544            waveforms = self._waveforms
 545            self._waveforms = [None] * 5
 546            for channel in range(1, 5):
 547                if waveforms[channel] is not None:
 548                    self.load_waveform(channel, waveforms[channel])
 549
 550    @property
 551    def loop_mode(self):
 552        """Whether each output channel loops its waveform.
 553
 554        Indexed by channel number (see "Channel settings" above). `True`
 555        repeats the waveform until `WavePalDevice.loop_duration` has
 556        elapsed, or until stopped if the loop duration is 0. `False` plays
 557        it once. A change applies to playback in progress.
 558        """
 559        return self._loop_mode
 560
 561    @loop_mode.setter
 562    def loop_mode(self, values):
 563        self._loop_mode._assign(values)
 564
 565    @property
 566    def loop_duration(self):
 567        """How long each output channel loops its waveform, in seconds.
 568
 569        Indexed by channel number (see "Channel settings" above). Applies
 570        in loop mode only, and counts from the trigger. The channel stops
 571        when it has elapsed, part way through the waveform if need be. `0`
 572        loops until the channel is stopped. Durations are rounded to a
 573        whole number of samples, and a nonzero duration lasts at least
 574        one sample.
 575        """
 576        return self._loop_duration
 577
 578    @loop_duration.setter
 579    def loop_duration(self, values):
 580        self._loop_duration._assign(values)
 581
 582    @property
 583    def trigger_mode(self):
 584        """How each output channel responds to a trigger.
 585
 586        Indexed by channel number (see "Channel settings" above). A
 587        trigger is a rising edge on a linked trigger channel, or a call to
 588        `WavePalDevice.play`:
 589
 590        - `"Normal"`: starts the waveform. Triggers during playback are
 591          ignored.
 592        - `"Master"`: starts the waveform, or restarts it from the first
 593          sample if it is playing.
 594        - `"Toggle"`: starts the waveform, or stops it if it is playing.
 595        - `"Gated"`: starts the waveform, and a falling edge on the
 596          trigger channel stops it, unless the other trigger channel is
 597          also linked and still high. The waveform plays while the TTL is
 598          high: with loop mode on and a loop duration of 0, it plays for
 599          exactly as long.
 600
 601        Names are not case sensitive.
 602        """
 603        return self._trigger_mode
 604
 605    @trigger_mode.setter
 606    def trigger_mode(self, values):
 607        self._trigger_mode._assign(values)
 608
 609    @property
 610    def link_trigger_channel1(self):
 611        """Whether each output channel is triggered by trigger channel 1.
 612
 613        Indexed by channel number (see "Channel settings" above).
 614        """
 615        return self._link_trigger_channel1
 616
 617    @link_trigger_channel1.setter
 618    def link_trigger_channel1(self, values):
 619        self._link_trigger_channel1._assign(values)
 620
 621    @property
 622    def link_trigger_channel2(self):
 623        """Whether each output channel is triggered by trigger channel 2.
 624
 625        Indexed by channel number (see "Channel settings" above).
 626        """
 627        return self._link_trigger_channel2
 628
 629    @link_trigger_channel2.setter
 630    def link_trigger_channel2(self, values):
 631        self._link_trigger_channel2._assign(values)
 632
 633    @property
 634    def waveforms(self):
 635        """The waveforms loaded with `WavePalDevice.load_waveform`.
 636
 637        A five element list indexed by channel number, with index 0
 638        unused. Each element is a read-only NumPy array of voltages, or
 639        `None` if this object has not loaded a waveform on that channel.
 640        `WavePalDevice.status` shows what the device itself holds.
 641        """
 642        return list(self._waveforms)
 643
 644    # ------------------------------------------------------------------
 645    # Waveforms and playback
 646    # ------------------------------------------------------------------
 647
 648    def load_waveform(self, channel, waveform):
 649        """Load a waveform onto an output channel.
 650
 651        The samples are written to the device's microSD card, and the
 652        first `DeviceInfo.buffer_samples` of them are also kept in its RAM.
 653        Loading stops the channel if it is playing. Other channels keep
 654        playing.
 655
 656        ```python
 657        t = np.arange(10000) / W.sampling_rate
 658        W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t))
 659        ```
 660
 661        Args:
 662            channel: Output channel number, 1-4.
 663            waveform: Voltages of the samples, played at
 664                `WavePalDevice.sampling_rate`. A list, tuple or NumPy
 665                array of 1 to `DeviceInfo.max_samples` values, all within
 666                `WavePalDevice.output_range`.
 667
 668        Raises:
 669            WavePalError: If the channel or waveform is invalid, or the
 670                device could not store the waveform. The channel is then
 671                left without a waveform.
 672        """
 673        channel = self._channel_number(channel)
 674        samples = np.array(waveform, dtype=float).ravel()
 675        if not 1 <= samples.size <= self.info.max_samples:
 676            raise WavePalError(
 677                f"A waveform must have 1 to {self.info.max_samples} samples. "
 678                f"Received {samples.size}."
 679            )
 680        codes = self._volts_to_codes(samples)
 681        self._waveforms[channel] = None  # The device unloads it first
 682        self._write_command(
 683            self._OP_LOAD_WAVEFORM,
 684            struct.pack("<BI", channel, samples.size) + codes.tobytes(),
 685        )
 686        self._read_ack("load_waveform()")
 687        samples.flags.writeable = False
 688        self._waveforms[channel] = samples
 689
 690    def play(self, channels):
 691        """Trigger output channels in software.
 692
 693        Each channel responds according to its
 694        `WavePalDevice.trigger_mode`, as if a linked trigger channel had
 695        gone high. Channels start on the same sample. Channels without a
 696        waveform are ignored.
 697
 698        ```python
 699        W.play(1)
 700        W.play([2, 4])
 701        ```
 702
 703        Args:
 704            channels: Output channel number 1-4, or a list of them.
 705        """
 706        bits = self._channel_bits(channels)
 707        self._write_command(self._OP_PLAY, bytes([bits]))
 708
 709    def stop(self, channels=None):
 710        """Stop playback. The stopped channels output 0 V.
 711
 712        Args:
 713            channels: Output channel number 1-4, or a list of them.
 714                `None` stops all channels.
 715        """
 716        if channels is None:
 717            bits = self._ALL_CHANNELS
 718        else:
 719            bits = self._channel_bits(channels)
 720        self._write_command(self._OP_STOP, bytes([bits]))
 721
 722    def set_fixed_voltage(self, channels, voltage):
 723        """Set output channels to a fixed voltage.
 724
 725        Stops playback on those channels. The voltage holds until the
 726        channel is triggered or stopped.
 727
 728        Args:
 729            channels: Output channel number 1-4, or a list of them.
 730            voltage: Voltage, within `WavePalDevice.output_range`.
 731
 732        Raises:
 733            WavePalError: If the voltage is outside the output range, or
 734                the device rejects the command.
 735        """
 736        bits = self._channel_bits(channels)
 737        code = int(self._volts_to_codes([voltage])[0])
 738        self._write_command(self._OP_SET_FIXED_VOLTAGE,
 739                            struct.pack("<BH", bits, code))
 740        self._read_ack("set_fixed_voltage()")
 741
 742    def status(self):
 743        """Read the device's playback state.
 744
 745        Returns:
 746            A `DeviceStatus`, with the channels that are playing, the
 747            number of samples each channel holds, underrun counts, and
 748            the longest playback interrupt.
 749        """
 750        self._write_command(self._OP_GET_STATUS)
 751        values = struct.unpack(
 752            self._STATUS_FORMAT,
 753            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
 754        )
 755        playing_bits = values[0]
 756        return DeviceStatus(
 757            playing=[ch for ch in range(1, 5)
 758                     if playing_bits & (1 << (ch - 1))],
 759            samples_loaded=[None, *values[1:5]],
 760            underruns=[None, *values[5:9]],
 761            longest_interrupt_us=values[9] / 1000,
 762        )
 763
 764    def _playback_checksums(self):
 765        """For testing: what each channel played since it last started.
 766
 767        Returns two lists indexed by channel number, with index 0 unused:
 768        the number of samples played, and the sum of their DAC codes
 769        modulo 2**32. `tests/wavepal_hardware_test.py` compares them with
 770        the waveforms it loaded, to check every sample the device played.
 771        """
 772        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
 773        values = struct.unpack("<8I", self._read_raw(32))
 774        return [None, *values[:4]], [None, *values[4:]]
 775
 776    # ------------------------------------------------------------------
 777    # Connection
 778    # ------------------------------------------------------------------
 779
 780    def close(self, send_disconnect=True):
 781        """Close the connection to the device.
 782
 783        The device shows its own name on its screen again, in place of
 784        "PYTHON Connected". It keeps its settings and waveforms, and
 785        playback in progress continues. Safe to call more than once.
 786        Called automatically when leaving a `with` block and when the
 787        object is garbage collected.
 788
 789        Args:
 790            send_disconnect: If `True`, tell the device that the client
 791                is disconnecting before closing the port. Set to `False`
 792                when the device may not be a Wave Pal.
 793        """
 794        if getattr(self, "_closed", True):
 795            return
 796        self._closed = True
 797        try:
 798            if send_disconnect and self.port and self.port.is_open:
 799                self._write_command(self._OP_DISCONNECT)
 800        except Exception:
 801            pass  # The port may already be gone, e.g. the cable was unplugged
 802        finally:
 803            if self.port and self.port.is_open:
 804                self.port.close()
 805
 806    def bytes_available(self):
 807        """Return the number of bytes waiting in the serial read buffer."""
 808        return self.port.in_waiting
 809
 810    def __enter__(self):
 811        """Enter a `with` block, returning the connected device."""
 812        return self
 813
 814    def __exit__(self, exc_type, exc_value, traceback):
 815        """Close the port when leaving a `with` block.
 816
 817        Returns:
 818            `False`, so any exception raised in the block propagates.
 819        """
 820        self.close()
 821        return False
 822
 823    def __del__(self):
 824        """Close the port when the object is collected."""
 825        try:
 826            self.close()
 827        except Exception:
 828            # Destructors should not raise; the serial object may already be
 829            # gone during interpreter shutdown.
 830            pass
 831
 832    def __repr__(self):
 833        """Describe the device and its settings."""
 834        port = getattr(getattr(self, "port", None), "port", None)
 835        loaded = ", ".join(
 836            f"{ch}: {'none' if w is None else f'{w.size} samples'}"
 837            for ch, w in enumerate(self._waveforms) if ch > 0
 838        )
 839        return (
 840            f"WavePalDevice on {port} (Wave Pal firmware "
 841            f"v{self.info.firmware_version})\n"
 842            f"sampling_rate: {self._sampling_rate} Hz\n"
 843            f"output_range: {self._output_range}\n"
 844            f"loop_mode: {list(self._loop_mode)}\n"
 845            f"loop_duration: {list(self._loop_duration)}\n"
 846            f"trigger_mode: {list(self._trigger_mode)}\n"
 847            f"link_trigger_channel1: {list(self._link_trigger_channel1)}\n"
 848            f"link_trigger_channel2: {list(self._link_trigger_channel2)}\n"
 849            f"waveforms: {loaded}"
 850        )
 851
 852    # ------------------------------------------------------------------
 853    # Internals
 854    # ------------------------------------------------------------------
 855
 856    def _handshake(self):
 857        """Check that the device runs a supported Wave Pal firmware."""
 858        self._write_command(self._OP_HANDSHAKE)
 859        try:
 860            reply = self._read_raw(1)[0]
 861        except WavePalError as exc:
 862            raise WavePalError(
 863                f"No reply from the device on {self.port.port}. Is it a "
 864                "Pulse Pal 3 running Wave Pal firmware?"
 865            ) from exc
 866        if reply == self._PULSE_PAL_HANDSHAKE_REPLY:
 867            version = struct.unpack("<I", self._read_raw(4))[0]
 868            raise WavePalError(
 869                f"The device on {self.port.port} runs Pulse Pal firmware "
 870                f"(v{version}). Load Wave Pal firmware onto it "
 871                "(/Firmware/WavePal), or connect with "
 872                "PulsePal.PulsePalDevice."
 873            )
 874        if reply != self._WAVE_PAL_HANDSHAKE_REPLY:
 875            raise WavePalError(
 876                "Incorrect handshake returned. Expected "
 877                f"{self._WAVE_PAL_HANDSHAKE_REPLY}, received {reply}."
 878            )
 879        version = struct.unpack("<I", self._read_raw(4))[0]
 880        if version > self._CURRENT_FIRMWARE_VERSION:
 881            raise WavePalError(
 882                f"Future firmware detected, v{version}. Please update "
 883                "WavePal.py or load Wave Pal firmware "
 884                f"v{self._CURRENT_FIRMWARE_VERSION}."
 885            )
 886        self.info.firmware_version = version
 887
 888    def _read_hardware_info(self):
 889        self._write_command(self._OP_HARDWARE_INFO)
 890        (
 891            self.info.hardware_version,
 892            self.info.n_channels,
 893            self.info.max_samples,
 894            self.info.max_sampling_rate,
 895            self.info.buffer_samples,
 896            self.info.sample_clock_hz,
 897        ) = struct.unpack(
 898            self._HARDWARE_INFO_FORMAT,
 899            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
 900        )
 901
 902    def _apply_loop_mode(self, values):
 903        modes = [self._to_bool(value, "loop_mode") for value in values]
 904        self._write_command(self._OP_SET_LOOP_MODE, bytes(map(int, modes)))
 905        self._read_ack("setting loop_mode")
 906        return modes
 907
 908    def _apply_loop_duration(self, values):
 909        durations = []
 910        for value in values:
 911            if isinstance(value, bool) or not isinstance(value, numbers.Real):
 912                raise WavePalError(
 913                    f"loop_duration must be in seconds. Received {value!r}."
 914                )
 915            duration = float(value)
 916            if not math.isfinite(duration) or duration < 0:
 917                raise WavePalError(
 918                    "loop_duration must be 0 (loop until stopped) or a "
 919                    f"positive number of seconds. Received {value!r}."
 920                )
 921            durations.append(duration)
 922        samples = self._durations_to_samples(durations, self._sampling_rate)
 923        self._send_loop_duration_samples(samples)
 924        return durations
 925
 926    def _send_loop_duration_samples(self, samples):
 927        self._write_command(self._OP_SET_LOOP_DURATION,
 928                            struct.pack("<4I", *samples))
 929        self._read_ack("setting loop_duration")
 930
 931    def _durations_to_samples(self, durations, rate_hz):
 932        """Convert loop durations in seconds to samples at a sampling rate."""
 933        actual_rate = self._actual_rate(rate_hz)
 934        samples = []
 935        for duration in durations:
 936            n = round(duration * actual_rate)
 937            if duration > 0:
 938                n = max(n, 1)  # 0 samples would mean "loop until stopped"
 939            if n > self._UINT32_MAX:
 940                raise WavePalError(
 941                    f"A loop_duration of {duration} s is too long at "
 942                    f"{rate_hz} Hz. The longest is "
 943                    f"{self._UINT32_MAX / actual_rate:.0f} s."
 944                )
 945            samples.append(n)
 946        return samples
 947
 948    def _apply_trigger_mode(self, values):
 949        names = []
 950        for value in values:
 951            matches = [mode for mode in TRIGGER_MODES
 952                       if isinstance(value, str)
 953                       and mode.lower() == value.lower()]
 954            if not matches:
 955                raise WavePalError(
 956                    f"Unknown trigger mode: {value!r}. Valid modes are "
 957                    f"{', '.join(TRIGGER_MODES)}."
 958                )
 959            names.append(matches[0])
 960        self._write_command(
 961            self._OP_SET_TRIGGER_MODE,
 962            bytes(TRIGGER_MODES.index(name) for name in names),
 963        )
 964        self._read_ack("setting trigger_mode")
 965        return names
 966
 967    # One op programs both trigger channels' links, so each list is sent
 968    # with the other's current values
 969    def _apply_trigger_channel1_links(self, values):
 970        links1 = [self._to_bool(v, "link_trigger_channel1") for v in values]
 971        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
 972        return links1
 973
 974    def _apply_trigger_channel2_links(self, values):
 975        links2 = [self._to_bool(v, "link_trigger_channel2") for v in values]
 976        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
 977        return links2
 978
 979    def _set_trigger_links(self, links1, links2):
 980        """Program both trigger channels' links with one command."""
 981        self._send_trigger_links(links1, links2)
 982        # Bypasses ChannelSettings.__setitem__, which would send them again
 983        list.__setitem__(self._link_trigger_channel1, slice(1, 5), links1)
 984        list.__setitem__(self._link_trigger_channel2, slice(1, 5), links2)
 985
 986    def _send_trigger_links(self, links1, links2):
 987        self._write_command(self._OP_SET_TRIGGER_LINKS,
 988                            bytes([*map(int, links1), *map(int, links2)]))
 989        self._read_ack("setting the trigger channel links")
 990
 991    def _actual_rate(self, rate_hz):
 992        clock = self.info.sample_clock_hz
 993        return clock / round(clock / rate_hz)
 994
 995    def _range_name(self, range_name):
 996        """Return the canonical name of an output range."""
 997        if isinstance(range_name, str):
 998            for name in OUTPUT_RANGES:
 999                if name.lower() == range_name.replace(" ", "").lower():
1000                    return name
1001        raise WavePalError(
1002            f"Unknown output range: {range_name!r}. Valid ranges are "
1003            f"{', '.join(OUTPUT_RANGES)}."
1004        )
1005
1006    def _volts_to_codes(self, volts):
1007        """Convert voltages to DAC codes in the current output range."""
1008        low, high = OUTPUT_RANGES[self._output_range]
1009        volts = np.asarray(volts, dtype=float)
1010        if not np.all(np.isfinite(volts)):
1011            raise WavePalError("Voltages must be finite numbers.")
1012        if volts.min() < low or volts.max() > high:
1013            raise WavePalError(
1014                f"Voltages must be within the output range, {low:g} V to "
1015                f"{high:g} V. Received {volts.min():g} V to "
1016                f"{volts.max():g} V. Change output_range to use a wider "
1017                "range."
1018            )
1019        codes = np.round((volts - low) / (high - low) * self._DAC_BITMAX)
1020        return codes.astype("<u2")
1021
1022    @staticmethod
1023    def _to_bool(value, name):
1024        if isinstance(value, (bool, np.bool_)):
1025            return bool(value)
1026        if isinstance(value, numbers.Integral) and value in (0, 1):
1027            return bool(value)
1028        raise WavePalError(f"{name} values must be True or False. "
1029                           f"Received {value!r}.")
1030
1031    @staticmethod
1032    def _channel_number(channel):
1033        if (
1034            not isinstance(channel, numbers.Integral)
1035            or isinstance(channel, bool)
1036            or not 1 <= channel <= 4
1037        ):
1038            raise WavePalError(
1039                f"Output channels are numbered 1-4. Received {channel!r}."
1040            )
1041        return int(channel)
1042
1043    def _channel_bits(self, channels):
1044        """Convert a channel number, or a list of them, to channel bits."""
1045        if isinstance(channels, numbers.Integral):
1046            channels = [channels]
1047        channels = list(channels)
1048        if not channels:
1049            raise WavePalError("No output channels were given.")
1050        bits = 0
1051        for channel in channels:
1052            bits |= 1 << (self._channel_number(channel) - 1)
1053        return bits
1054
1055    def _write_command(self, op_code, data=b""):
1056        """Send one command, with its framing byte, in a single write."""
1057        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1058        bytes_written = self.port.write(message)
1059        if bytes_written != len(message):
1060            raise WavePalError(
1061                f"Wrote {bytes_written} byte(s), expected to write "
1062                f"{len(message)} byte(s)."
1063            )
1064
1065    def _read_raw(self, n_bytes):
1066        """Read exactly n_bytes from the serial port."""
1067        message = self.port.read(n_bytes)
1068        if len(message) < n_bytes:
1069            raise WavePalError(
1070                f"Serial port timed out. {len(message)} byte(s) read. "
1071                f"Expected {n_bytes} byte(s)."
1072            )
1073        return message
1074
1075    def _read_ack(self, context):
1076        """Read a one-byte confirmation: 1 if the device executed the
1077        command, 0 if it rejected it."""
1078        try:
1079            reply = self._read_raw(1)[0]
1080        except WavePalError as exc:
1081            raise WavePalError(
1082                f"Wave Pal did not confirm {context}."
1083            ) from exc
1084        if reply != 1:
1085            raise WavePalError(
1086                f"Wave Pal rejected {context}. A value was out of range, "
1087                "or a waveform could not be written to the microSD card."
1088            )
class WavePalDevice:
 262class WavePalDevice:
 263    """A class to control a Wave Pal on a USB serial port.
 264
 265    Creating an instance opens the serial port, checks that the device
 266    runs Wave Pal firmware, reads its properties into
 267    `WavePalDevice.info`, shows "PYTHON Connected" on the device's
 268    screen, stops any playback and programs the default settings (see
 269    `WavePalDevice.set_defaults`).
 270
 271    ```python
 272    from WavePal import WavePalDevice
 273
 274    W = WavePalDevice("COM3")
 275    W.load_waveform(1, [0, 1, 2, 3, 4, 5, 0])
 276    W.play(1)
 277    W.close()
 278    ```
 279
 280    Replace "COM3" with the device's USB serial port name, which
 281    `WavePalDevice.serialportlist` lists. `WavePalDevice` is also a
 282    context manager, which closes the port on exit.
 283
 284    Closing the connection puts the device's own name back on its
 285    screen, and leaves everything else as it is: playback continues, and
 286    TTL triggers keep playing the loaded waveforms.
 287    """
 288
 289    port: "serial.Serial"
 290    """The open `serial.Serial` port connected to the device."""
 291
 292    info: DeviceInfo
 293    """Properties of the connected device. See `DeviceInfo`."""
 294
 295    _CURRENT_FIRMWARE_VERSION = 1
 296
 297    _OP_MENU_BYTE = 213
 298    _OP_HANDSHAKE = 72
 299    _OP_DISCONNECT = 81
 300    _OP_SET_CLIENT_NAME = 89
 301    _OP_HARDWARE_INFO = ord("N")
 302    _OP_SET_SAMPLING_RATE = ord("S")
 303    _OP_SET_OUTPUT_RANGE = ord("R")
 304    _OP_LOAD_WAVEFORM = ord("L")
 305    _OP_PLAY = ord("P")
 306    _OP_STOP = ord("X")
 307    _OP_SET_FIXED_VOLTAGE = ord("!")
 308    _OP_SET_LOOP_MODE = ord("O")
 309    _OP_SET_LOOP_DURATION = ord("D")
 310    _OP_SET_TRIGGER_MODE = ord("T")
 311    _OP_SET_TRIGGER_LINKS = ord("I")
 312    _OP_GET_STATUS = ord("G")
 313    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 314
 315    _WAVE_PAL_HANDSHAKE_REPLY = 87  # 'W'
 316    _PULSE_PAL_HANDSHAKE_REPLY = 75  # 'K': the device runs Pulse Pal firmware
 317    _HARDWARE_INFO_FORMAT = "<BBIIII"
 318    _STATUS_FORMAT = "<B4I4II"
 319    _DAC_BITMAX = 65535
 320    _UINT32_MAX = 2**32 - 1
 321    _ALL_CHANNELS = 0x0F
 322
 323    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 324        """Open a connection to a Wave Pal.
 325
 326        Args:
 327            port_name: USB serial port of the device, such as `COM3` on
 328                Windows or `/dev/ttyACM0` on Linux.
 329            baud_rate: Serial baud rate. USB serial ignores it.
 330            timeout: Serial read timeout, in seconds. Loading a long
 331                waveform onto a slow microSD card can take a few seconds.
 332
 333        Raises:
 334            WavePalError: If the device does not reply to the handshake,
 335                runs Pulse Pal firmware, or runs Wave Pal firmware newer
 336                than this module supports.
 337            serial.SerialException: If the serial port cannot be opened.
 338        """
 339        self._closed = True
 340        self.info = DeviceInfo()
 341        self._sampling_rate = None
 342        self._output_range = None
 343        self._waveforms = [None] * 5
 344        self._loop_mode = ChannelSettings(
 345            "loop_mode", [False] * 4, self, "_apply_loop_mode")
 346        self._loop_duration = ChannelSettings(
 347            "loop_duration", [0.0] * 4, self, "_apply_loop_duration")
 348        self._trigger_mode = ChannelSettings(
 349            "trigger_mode", ["Normal"] * 4, self, "_apply_trigger_mode")
 350        self._link_trigger_channel1 = ChannelSettings(
 351            "link_trigger_channel1", [True] * 4, self,
 352            "_apply_trigger_channel1_links")
 353        self._link_trigger_channel2 = ChannelSettings(
 354            "link_trigger_channel2", [False] * 4, self,
 355            "_apply_trigger_channel2_links")
 356
 357        self.port = serial.Serial(
 358            port_name,
 359            baud_rate,
 360            timeout=timeout,
 361            rtscts=True,
 362        )
 363        self._closed = False
 364        try:
 365            # Discard anything left in the buffer by an earlier session
 366            self.port.reset_input_buffer()
 367            self._handshake()
 368            self._read_hardware_info()
 369            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 370            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 371            self.stop()
 372            self.set_defaults()
 373        except BaseException:
 374            # Op 81 means something else to other devices, so it is sent
 375            # only once the device has identified itself as a Wave Pal
 376            self.close(
 377                send_disconnect=self.info.firmware_version is not None)
 378            raise
 379
 380    @staticmethod
 381    def serialportlist(ports_to_list="available"):
 382        """Return the names of the USB serial ports on this computer.
 383
 384        Called on the class, without connecting to a device, to find the
 385        port name to pass to `WavePalDevice`.
 386
 387        Args:
 388            ports_to_list: `available` to list only the ports that are
 389                not already in use, or `all` to list every USB serial
 390                port. Not case sensitive.
 391
 392        Returns:
 393            Sorted list of port names, such as `["COM3", "COM7"]`.
 394
 395        Raises:
 396            WavePalError: If `ports_to_list` is not `available` or `all`.
 397        """
 398        mode = str(ports_to_list).lower()
 399        if mode not in ("available", "all"):
 400            raise WavePalError(
 401                f"Unknown port list type: {ports_to_list}. "
 402                "Use 'available' or 'all'."
 403            )
 404        port_names = []
 405        for port_info in serial.tools.list_ports.comports():
 406            is_usb = port_info.vid is not None or "USB" in (
 407                port_info.hwid or ""
 408            ).upper()
 409            if not is_usb:
 410                continue
 411            if mode == "available" and not WavePalDevice._port_is_free(
 412                port_info.device
 413            ):
 414                continue
 415            port_names.append(port_info.device)
 416        return sorted(port_names)
 417
 418    @staticmethod
 419    def _port_is_free(port_name):
 420        """Return True if the port is not already open in another program."""
 421        port = serial.Serial()
 422        port.port = port_name
 423        # Leaving the control lines low avoids resetting boards that
 424        # reset on DTR while the port is probed.
 425        port.dtr = False
 426        port.rts = False
 427        try:
 428            port.open()
 429        except (serial.SerialException, OSError):
 430            return False
 431        port.close()
 432        return True
 433
 434    # ------------------------------------------------------------------
 435    # Settings
 436    # ------------------------------------------------------------------
 437
 438    def set_defaults(self):
 439        """Program the default settings on the device.
 440
 441        The defaults are a 10 kHz sampling rate, the -10 V to 10 V output
 442        range, loop mode off with loop durations of 0, normal trigger
 443        mode, and all output channels linked to trigger channel 1 and not
 444        to trigger channel 2. They match the settings the device starts
 445        with.
 446
 447        Loaded waveforms are kept, and loaded again if the output range
 448        changes (see `WavePalDevice.output_range`).
 449
 450        Raises:
 451            WavePalError: If a loaded waveform does not fit the default
 452                output range, -10 V to 10 V.
 453        """
 454        self.sampling_rate = 10000
 455        self.output_range = "-10V:10V"
 456        self.loop_mode = False
 457        self.loop_duration = 0
 458        self.trigger_mode = "Normal"
 459        self._set_trigger_links([True] * 4, [False] * 4)
 460
 461    @property
 462    def sampling_rate(self):
 463        """Sampling rate of all output channels, in Hz.
 464
 465        A whole number from 1 to `DeviceInfo.max_sampling_rate`. It can be
 466        changed during playback. The rate played can differ slightly from
 467        the rate set: see `WavePalDevice.actual_sampling_rate`.
 468        """
 469        return self._sampling_rate
 470
 471    @sampling_rate.setter
 472    def sampling_rate(self, rate):
 473        try:
 474            rate_hz = int(rate)
 475            is_whole = rate_hz == rate and not isinstance(rate, bool)
 476        except (TypeError, ValueError, OverflowError):
 477            is_whole = False
 478        if not is_whole or not 1 <= rate_hz <= self.info.max_sampling_rate:
 479            raise WavePalError(
 480                "sampling_rate must be a whole number of Hz from 1 to "
 481                f"{self.info.max_sampling_rate}. Received {rate!r}."
 482            )
 483        # Loop durations are sent in samples, so check that they still fit
 484        # before anything is changed
 485        loop_samples = None
 486        if self._sampling_rate is not None:
 487            loop_samples = self._durations_to_samples(
 488                self._loop_duration[1:], rate_hz)
 489        self._write_command(self._OP_SET_SAMPLING_RATE,
 490                            struct.pack("<I", rate_hz))
 491        self._read_ack("setting sampling_rate")
 492        self._sampling_rate = rate_hz
 493        if loop_samples is not None:
 494            self._send_loop_duration_samples(loop_samples)
 495
 496    @property
 497    def actual_sampling_rate(self):
 498        """The sampling rate the device plays, in Hz.
 499
 500        The device divides `DeviceInfo.sample_clock_hz` (24 MHz) by a
 501        whole number, so the rate played is the nearest one of those to
 502        `WavePalDevice.sampling_rate`. For example, 44100 Hz plays at
 503        44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz,
 504        25 kHz or 100 kHz, play exactly.
 505        """
 506        return self._actual_rate(self._sampling_rate)
 507
 508    @property
 509    def output_range(self):
 510        """Voltage range of all output channels.
 511
 512        One of `DeviceInfo.output_ranges`: `"0V:5V"`, `"0V:10V"`,
 513        `"-5V:5V"` or `"-10V:10V"`. The smallest range that fits the
 514        waveforms gives the finest voltage steps.
 515
 516        Changing the range stops playback, sets all outputs to 0 V, and
 517        loads the waveforms again, encoded for the new range. It raises
 518        an error, and changes nothing, if a loaded waveform does not fit
 519        the new range.
 520        """
 521        return self._output_range
 522
 523    @output_range.setter
 524    def output_range(self, range_name):
 525        name = self._range_name(range_name)
 526        low, high = OUTPUT_RANGES[name]
 527        for channel in range(1, 5):
 528            waveform = self._waveforms[channel]
 529            if waveform is not None and (
 530                    waveform.min() < low or waveform.max() > high):
 531                raise WavePalError(
 532                    f"The waveform on channel {channel} spans "
 533                    f"{waveform.min():g} V to {waveform.max():g} V, which "
 534                    f"does not fit the {name} range. Load a new waveform "
 535                    "first, or choose a wider range."
 536                )
 537        range_index = list(OUTPUT_RANGES).index(name)
 538        self._write_command(self._OP_SET_OUTPUT_RANGE, bytes([range_index]))
 539        self._read_ack("setting output_range")
 540        changed = name != self._output_range
 541        self._output_range = name
 542        if changed:
 543            # The device unloads the waveforms when the range changes,
 544            # because their samples encode voltages in the old range
 545            waveforms = self._waveforms
 546            self._waveforms = [None] * 5
 547            for channel in range(1, 5):
 548                if waveforms[channel] is not None:
 549                    self.load_waveform(channel, waveforms[channel])
 550
 551    @property
 552    def loop_mode(self):
 553        """Whether each output channel loops its waveform.
 554
 555        Indexed by channel number (see "Channel settings" above). `True`
 556        repeats the waveform until `WavePalDevice.loop_duration` has
 557        elapsed, or until stopped if the loop duration is 0. `False` plays
 558        it once. A change applies to playback in progress.
 559        """
 560        return self._loop_mode
 561
 562    @loop_mode.setter
 563    def loop_mode(self, values):
 564        self._loop_mode._assign(values)
 565
 566    @property
 567    def loop_duration(self):
 568        """How long each output channel loops its waveform, in seconds.
 569
 570        Indexed by channel number (see "Channel settings" above). Applies
 571        in loop mode only, and counts from the trigger. The channel stops
 572        when it has elapsed, part way through the waveform if need be. `0`
 573        loops until the channel is stopped. Durations are rounded to a
 574        whole number of samples, and a nonzero duration lasts at least
 575        one sample.
 576        """
 577        return self._loop_duration
 578
 579    @loop_duration.setter
 580    def loop_duration(self, values):
 581        self._loop_duration._assign(values)
 582
 583    @property
 584    def trigger_mode(self):
 585        """How each output channel responds to a trigger.
 586
 587        Indexed by channel number (see "Channel settings" above). A
 588        trigger is a rising edge on a linked trigger channel, or a call to
 589        `WavePalDevice.play`:
 590
 591        - `"Normal"`: starts the waveform. Triggers during playback are
 592          ignored.
 593        - `"Master"`: starts the waveform, or restarts it from the first
 594          sample if it is playing.
 595        - `"Toggle"`: starts the waveform, or stops it if it is playing.
 596        - `"Gated"`: starts the waveform, and a falling edge on the
 597          trigger channel stops it, unless the other trigger channel is
 598          also linked and still high. The waveform plays while the TTL is
 599          high: with loop mode on and a loop duration of 0, it plays for
 600          exactly as long.
 601
 602        Names are not case sensitive.
 603        """
 604        return self._trigger_mode
 605
 606    @trigger_mode.setter
 607    def trigger_mode(self, values):
 608        self._trigger_mode._assign(values)
 609
 610    @property
 611    def link_trigger_channel1(self):
 612        """Whether each output channel is triggered by trigger channel 1.
 613
 614        Indexed by channel number (see "Channel settings" above).
 615        """
 616        return self._link_trigger_channel1
 617
 618    @link_trigger_channel1.setter
 619    def link_trigger_channel1(self, values):
 620        self._link_trigger_channel1._assign(values)
 621
 622    @property
 623    def link_trigger_channel2(self):
 624        """Whether each output channel is triggered by trigger channel 2.
 625
 626        Indexed by channel number (see "Channel settings" above).
 627        """
 628        return self._link_trigger_channel2
 629
 630    @link_trigger_channel2.setter
 631    def link_trigger_channel2(self, values):
 632        self._link_trigger_channel2._assign(values)
 633
 634    @property
 635    def waveforms(self):
 636        """The waveforms loaded with `WavePalDevice.load_waveform`.
 637
 638        A five element list indexed by channel number, with index 0
 639        unused. Each element is a read-only NumPy array of voltages, or
 640        `None` if this object has not loaded a waveform on that channel.
 641        `WavePalDevice.status` shows what the device itself holds.
 642        """
 643        return list(self._waveforms)
 644
 645    # ------------------------------------------------------------------
 646    # Waveforms and playback
 647    # ------------------------------------------------------------------
 648
 649    def load_waveform(self, channel, waveform):
 650        """Load a waveform onto an output channel.
 651
 652        The samples are written to the device's microSD card, and the
 653        first `DeviceInfo.buffer_samples` of them are also kept in its RAM.
 654        Loading stops the channel if it is playing. Other channels keep
 655        playing.
 656
 657        ```python
 658        t = np.arange(10000) / W.sampling_rate
 659        W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t))
 660        ```
 661
 662        Args:
 663            channel: Output channel number, 1-4.
 664            waveform: Voltages of the samples, played at
 665                `WavePalDevice.sampling_rate`. A list, tuple or NumPy
 666                array of 1 to `DeviceInfo.max_samples` values, all within
 667                `WavePalDevice.output_range`.
 668
 669        Raises:
 670            WavePalError: If the channel or waveform is invalid, or the
 671                device could not store the waveform. The channel is then
 672                left without a waveform.
 673        """
 674        channel = self._channel_number(channel)
 675        samples = np.array(waveform, dtype=float).ravel()
 676        if not 1 <= samples.size <= self.info.max_samples:
 677            raise WavePalError(
 678                f"A waveform must have 1 to {self.info.max_samples} samples. "
 679                f"Received {samples.size}."
 680            )
 681        codes = self._volts_to_codes(samples)
 682        self._waveforms[channel] = None  # The device unloads it first
 683        self._write_command(
 684            self._OP_LOAD_WAVEFORM,
 685            struct.pack("<BI", channel, samples.size) + codes.tobytes(),
 686        )
 687        self._read_ack("load_waveform()")
 688        samples.flags.writeable = False
 689        self._waveforms[channel] = samples
 690
 691    def play(self, channels):
 692        """Trigger output channels in software.
 693
 694        Each channel responds according to its
 695        `WavePalDevice.trigger_mode`, as if a linked trigger channel had
 696        gone high. Channels start on the same sample. Channels without a
 697        waveform are ignored.
 698
 699        ```python
 700        W.play(1)
 701        W.play([2, 4])
 702        ```
 703
 704        Args:
 705            channels: Output channel number 1-4, or a list of them.
 706        """
 707        bits = self._channel_bits(channels)
 708        self._write_command(self._OP_PLAY, bytes([bits]))
 709
 710    def stop(self, channels=None):
 711        """Stop playback. The stopped channels output 0 V.
 712
 713        Args:
 714            channels: Output channel number 1-4, or a list of them.
 715                `None` stops all channels.
 716        """
 717        if channels is None:
 718            bits = self._ALL_CHANNELS
 719        else:
 720            bits = self._channel_bits(channels)
 721        self._write_command(self._OP_STOP, bytes([bits]))
 722
 723    def set_fixed_voltage(self, channels, voltage):
 724        """Set output channels to a fixed voltage.
 725
 726        Stops playback on those channels. The voltage holds until the
 727        channel is triggered or stopped.
 728
 729        Args:
 730            channels: Output channel number 1-4, or a list of them.
 731            voltage: Voltage, within `WavePalDevice.output_range`.
 732
 733        Raises:
 734            WavePalError: If the voltage is outside the output range, or
 735                the device rejects the command.
 736        """
 737        bits = self._channel_bits(channels)
 738        code = int(self._volts_to_codes([voltage])[0])
 739        self._write_command(self._OP_SET_FIXED_VOLTAGE,
 740                            struct.pack("<BH", bits, code))
 741        self._read_ack("set_fixed_voltage()")
 742
 743    def status(self):
 744        """Read the device's playback state.
 745
 746        Returns:
 747            A `DeviceStatus`, with the channels that are playing, the
 748            number of samples each channel holds, underrun counts, and
 749            the longest playback interrupt.
 750        """
 751        self._write_command(self._OP_GET_STATUS)
 752        values = struct.unpack(
 753            self._STATUS_FORMAT,
 754            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
 755        )
 756        playing_bits = values[0]
 757        return DeviceStatus(
 758            playing=[ch for ch in range(1, 5)
 759                     if playing_bits & (1 << (ch - 1))],
 760            samples_loaded=[None, *values[1:5]],
 761            underruns=[None, *values[5:9]],
 762            longest_interrupt_us=values[9] / 1000,
 763        )
 764
 765    def _playback_checksums(self):
 766        """For testing: what each channel played since it last started.
 767
 768        Returns two lists indexed by channel number, with index 0 unused:
 769        the number of samples played, and the sum of their DAC codes
 770        modulo 2**32. `tests/wavepal_hardware_test.py` compares them with
 771        the waveforms it loaded, to check every sample the device played.
 772        """
 773        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
 774        values = struct.unpack("<8I", self._read_raw(32))
 775        return [None, *values[:4]], [None, *values[4:]]
 776
 777    # ------------------------------------------------------------------
 778    # Connection
 779    # ------------------------------------------------------------------
 780
 781    def close(self, send_disconnect=True):
 782        """Close the connection to the device.
 783
 784        The device shows its own name on its screen again, in place of
 785        "PYTHON Connected". It keeps its settings and waveforms, and
 786        playback in progress continues. Safe to call more than once.
 787        Called automatically when leaving a `with` block and when the
 788        object is garbage collected.
 789
 790        Args:
 791            send_disconnect: If `True`, tell the device that the client
 792                is disconnecting before closing the port. Set to `False`
 793                when the device may not be a Wave Pal.
 794        """
 795        if getattr(self, "_closed", True):
 796            return
 797        self._closed = True
 798        try:
 799            if send_disconnect and self.port and self.port.is_open:
 800                self._write_command(self._OP_DISCONNECT)
 801        except Exception:
 802            pass  # The port may already be gone, e.g. the cable was unplugged
 803        finally:
 804            if self.port and self.port.is_open:
 805                self.port.close()
 806
 807    def bytes_available(self):
 808        """Return the number of bytes waiting in the serial read buffer."""
 809        return self.port.in_waiting
 810
 811    def __enter__(self):
 812        """Enter a `with` block, returning the connected device."""
 813        return self
 814
 815    def __exit__(self, exc_type, exc_value, traceback):
 816        """Close the port when leaving a `with` block.
 817
 818        Returns:
 819            `False`, so any exception raised in the block propagates.
 820        """
 821        self.close()
 822        return False
 823
 824    def __del__(self):
 825        """Close the port when the object is collected."""
 826        try:
 827            self.close()
 828        except Exception:
 829            # Destructors should not raise; the serial object may already be
 830            # gone during interpreter shutdown.
 831            pass
 832
 833    def __repr__(self):
 834        """Describe the device and its settings."""
 835        port = getattr(getattr(self, "port", None), "port", None)
 836        loaded = ", ".join(
 837            f"{ch}: {'none' if w is None else f'{w.size} samples'}"
 838            for ch, w in enumerate(self._waveforms) if ch > 0
 839        )
 840        return (
 841            f"WavePalDevice on {port} (Wave Pal firmware "
 842            f"v{self.info.firmware_version})\n"
 843            f"sampling_rate: {self._sampling_rate} Hz\n"
 844            f"output_range: {self._output_range}\n"
 845            f"loop_mode: {list(self._loop_mode)}\n"
 846            f"loop_duration: {list(self._loop_duration)}\n"
 847            f"trigger_mode: {list(self._trigger_mode)}\n"
 848            f"link_trigger_channel1: {list(self._link_trigger_channel1)}\n"
 849            f"link_trigger_channel2: {list(self._link_trigger_channel2)}\n"
 850            f"waveforms: {loaded}"
 851        )
 852
 853    # ------------------------------------------------------------------
 854    # Internals
 855    # ------------------------------------------------------------------
 856
 857    def _handshake(self):
 858        """Check that the device runs a supported Wave Pal firmware."""
 859        self._write_command(self._OP_HANDSHAKE)
 860        try:
 861            reply = self._read_raw(1)[0]
 862        except WavePalError as exc:
 863            raise WavePalError(
 864                f"No reply from the device on {self.port.port}. Is it a "
 865                "Pulse Pal 3 running Wave Pal firmware?"
 866            ) from exc
 867        if reply == self._PULSE_PAL_HANDSHAKE_REPLY:
 868            version = struct.unpack("<I", self._read_raw(4))[0]
 869            raise WavePalError(
 870                f"The device on {self.port.port} runs Pulse Pal firmware "
 871                f"(v{version}). Load Wave Pal firmware onto it "
 872                "(/Firmware/WavePal), or connect with "
 873                "PulsePal.PulsePalDevice."
 874            )
 875        if reply != self._WAVE_PAL_HANDSHAKE_REPLY:
 876            raise WavePalError(
 877                "Incorrect handshake returned. Expected "
 878                f"{self._WAVE_PAL_HANDSHAKE_REPLY}, received {reply}."
 879            )
 880        version = struct.unpack("<I", self._read_raw(4))[0]
 881        if version > self._CURRENT_FIRMWARE_VERSION:
 882            raise WavePalError(
 883                f"Future firmware detected, v{version}. Please update "
 884                "WavePal.py or load Wave Pal firmware "
 885                f"v{self._CURRENT_FIRMWARE_VERSION}."
 886            )
 887        self.info.firmware_version = version
 888
 889    def _read_hardware_info(self):
 890        self._write_command(self._OP_HARDWARE_INFO)
 891        (
 892            self.info.hardware_version,
 893            self.info.n_channels,
 894            self.info.max_samples,
 895            self.info.max_sampling_rate,
 896            self.info.buffer_samples,
 897            self.info.sample_clock_hz,
 898        ) = struct.unpack(
 899            self._HARDWARE_INFO_FORMAT,
 900            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
 901        )
 902
 903    def _apply_loop_mode(self, values):
 904        modes = [self._to_bool(value, "loop_mode") for value in values]
 905        self._write_command(self._OP_SET_LOOP_MODE, bytes(map(int, modes)))
 906        self._read_ack("setting loop_mode")
 907        return modes
 908
 909    def _apply_loop_duration(self, values):
 910        durations = []
 911        for value in values:
 912            if isinstance(value, bool) or not isinstance(value, numbers.Real):
 913                raise WavePalError(
 914                    f"loop_duration must be in seconds. Received {value!r}."
 915                )
 916            duration = float(value)
 917            if not math.isfinite(duration) or duration < 0:
 918                raise WavePalError(
 919                    "loop_duration must be 0 (loop until stopped) or a "
 920                    f"positive number of seconds. Received {value!r}."
 921                )
 922            durations.append(duration)
 923        samples = self._durations_to_samples(durations, self._sampling_rate)
 924        self._send_loop_duration_samples(samples)
 925        return durations
 926
 927    def _send_loop_duration_samples(self, samples):
 928        self._write_command(self._OP_SET_LOOP_DURATION,
 929                            struct.pack("<4I", *samples))
 930        self._read_ack("setting loop_duration")
 931
 932    def _durations_to_samples(self, durations, rate_hz):
 933        """Convert loop durations in seconds to samples at a sampling rate."""
 934        actual_rate = self._actual_rate(rate_hz)
 935        samples = []
 936        for duration in durations:
 937            n = round(duration * actual_rate)
 938            if duration > 0:
 939                n = max(n, 1)  # 0 samples would mean "loop until stopped"
 940            if n > self._UINT32_MAX:
 941                raise WavePalError(
 942                    f"A loop_duration of {duration} s is too long at "
 943                    f"{rate_hz} Hz. The longest is "
 944                    f"{self._UINT32_MAX / actual_rate:.0f} s."
 945                )
 946            samples.append(n)
 947        return samples
 948
 949    def _apply_trigger_mode(self, values):
 950        names = []
 951        for value in values:
 952            matches = [mode for mode in TRIGGER_MODES
 953                       if isinstance(value, str)
 954                       and mode.lower() == value.lower()]
 955            if not matches:
 956                raise WavePalError(
 957                    f"Unknown trigger mode: {value!r}. Valid modes are "
 958                    f"{', '.join(TRIGGER_MODES)}."
 959                )
 960            names.append(matches[0])
 961        self._write_command(
 962            self._OP_SET_TRIGGER_MODE,
 963            bytes(TRIGGER_MODES.index(name) for name in names),
 964        )
 965        self._read_ack("setting trigger_mode")
 966        return names
 967
 968    # One op programs both trigger channels' links, so each list is sent
 969    # with the other's current values
 970    def _apply_trigger_channel1_links(self, values):
 971        links1 = [self._to_bool(v, "link_trigger_channel1") for v in values]
 972        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
 973        return links1
 974
 975    def _apply_trigger_channel2_links(self, values):
 976        links2 = [self._to_bool(v, "link_trigger_channel2") for v in values]
 977        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
 978        return links2
 979
 980    def _set_trigger_links(self, links1, links2):
 981        """Program both trigger channels' links with one command."""
 982        self._send_trigger_links(links1, links2)
 983        # Bypasses ChannelSettings.__setitem__, which would send them again
 984        list.__setitem__(self._link_trigger_channel1, slice(1, 5), links1)
 985        list.__setitem__(self._link_trigger_channel2, slice(1, 5), links2)
 986
 987    def _send_trigger_links(self, links1, links2):
 988        self._write_command(self._OP_SET_TRIGGER_LINKS,
 989                            bytes([*map(int, links1), *map(int, links2)]))
 990        self._read_ack("setting the trigger channel links")
 991
 992    def _actual_rate(self, rate_hz):
 993        clock = self.info.sample_clock_hz
 994        return clock / round(clock / rate_hz)
 995
 996    def _range_name(self, range_name):
 997        """Return the canonical name of an output range."""
 998        if isinstance(range_name, str):
 999            for name in OUTPUT_RANGES:
1000                if name.lower() == range_name.replace(" ", "").lower():
1001                    return name
1002        raise WavePalError(
1003            f"Unknown output range: {range_name!r}. Valid ranges are "
1004            f"{', '.join(OUTPUT_RANGES)}."
1005        )
1006
1007    def _volts_to_codes(self, volts):
1008        """Convert voltages to DAC codes in the current output range."""
1009        low, high = OUTPUT_RANGES[self._output_range]
1010        volts = np.asarray(volts, dtype=float)
1011        if not np.all(np.isfinite(volts)):
1012            raise WavePalError("Voltages must be finite numbers.")
1013        if volts.min() < low or volts.max() > high:
1014            raise WavePalError(
1015                f"Voltages must be within the output range, {low:g} V to "
1016                f"{high:g} V. Received {volts.min():g} V to "
1017                f"{volts.max():g} V. Change output_range to use a wider "
1018                "range."
1019            )
1020        codes = np.round((volts - low) / (high - low) * self._DAC_BITMAX)
1021        return codes.astype("<u2")
1022
1023    @staticmethod
1024    def _to_bool(value, name):
1025        if isinstance(value, (bool, np.bool_)):
1026            return bool(value)
1027        if isinstance(value, numbers.Integral) and value in (0, 1):
1028            return bool(value)
1029        raise WavePalError(f"{name} values must be True or False. "
1030                           f"Received {value!r}.")
1031
1032    @staticmethod
1033    def _channel_number(channel):
1034        if (
1035            not isinstance(channel, numbers.Integral)
1036            or isinstance(channel, bool)
1037            or not 1 <= channel <= 4
1038        ):
1039            raise WavePalError(
1040                f"Output channels are numbered 1-4. Received {channel!r}."
1041            )
1042        return int(channel)
1043
1044    def _channel_bits(self, channels):
1045        """Convert a channel number, or a list of them, to channel bits."""
1046        if isinstance(channels, numbers.Integral):
1047            channels = [channels]
1048        channels = list(channels)
1049        if not channels:
1050            raise WavePalError("No output channels were given.")
1051        bits = 0
1052        for channel in channels:
1053            bits |= 1 << (self._channel_number(channel) - 1)
1054        return bits
1055
1056    def _write_command(self, op_code, data=b""):
1057        """Send one command, with its framing byte, in a single write."""
1058        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1059        bytes_written = self.port.write(message)
1060        if bytes_written != len(message):
1061            raise WavePalError(
1062                f"Wrote {bytes_written} byte(s), expected to write "
1063                f"{len(message)} byte(s)."
1064            )
1065
1066    def _read_raw(self, n_bytes):
1067        """Read exactly n_bytes from the serial port."""
1068        message = self.port.read(n_bytes)
1069        if len(message) < n_bytes:
1070            raise WavePalError(
1071                f"Serial port timed out. {len(message)} byte(s) read. "
1072                f"Expected {n_bytes} byte(s)."
1073            )
1074        return message
1075
1076    def _read_ack(self, context):
1077        """Read a one-byte confirmation: 1 if the device executed the
1078        command, 0 if it rejected it."""
1079        try:
1080            reply = self._read_raw(1)[0]
1081        except WavePalError as exc:
1082            raise WavePalError(
1083                f"Wave Pal did not confirm {context}."
1084            ) from exc
1085        if reply != 1:
1086            raise WavePalError(
1087                f"Wave Pal rejected {context}. A value was out of range, "
1088                "or a waveform could not be written to the microSD card."
1089            )

A class to control a Wave Pal on a USB serial port.

Creating an instance opens the serial port, checks that the device runs Wave Pal firmware, reads its properties into WavePalDevice.info, shows "PYTHON Connected" on the device's screen, stops any playback and programs the default settings (see WavePalDevice.set_defaults).

from WavePal import WavePalDevice

W = WavePalDevice("COM3")
W.load_waveform(1, [0, 1, 2, 3, 4, 5, 0])
W.play(1)
W.close()

Replace "COM3" with the device's USB serial port name, which WavePalDevice.serialportlist lists. WavePalDevice is also a context manager, which closes the port on exit.

Closing the connection puts the device's own name back on its screen, and leaves everything else as it is: playback continues, and TTL triggers keep playing the loaded waveforms.

WavePalDevice(port_name, baud_rate=12000000, timeout=10)
323    def __init__(self, port_name, baud_rate=12000000, timeout=10):
324        """Open a connection to a Wave Pal.
325
326        Args:
327            port_name: USB serial port of the device, such as `COM3` on
328                Windows or `/dev/ttyACM0` on Linux.
329            baud_rate: Serial baud rate. USB serial ignores it.
330            timeout: Serial read timeout, in seconds. Loading a long
331                waveform onto a slow microSD card can take a few seconds.
332
333        Raises:
334            WavePalError: If the device does not reply to the handshake,
335                runs Pulse Pal firmware, or runs Wave Pal firmware newer
336                than this module supports.
337            serial.SerialException: If the serial port cannot be opened.
338        """
339        self._closed = True
340        self.info = DeviceInfo()
341        self._sampling_rate = None
342        self._output_range = None
343        self._waveforms = [None] * 5
344        self._loop_mode = ChannelSettings(
345            "loop_mode", [False] * 4, self, "_apply_loop_mode")
346        self._loop_duration = ChannelSettings(
347            "loop_duration", [0.0] * 4, self, "_apply_loop_duration")
348        self._trigger_mode = ChannelSettings(
349            "trigger_mode", ["Normal"] * 4, self, "_apply_trigger_mode")
350        self._link_trigger_channel1 = ChannelSettings(
351            "link_trigger_channel1", [True] * 4, self,
352            "_apply_trigger_channel1_links")
353        self._link_trigger_channel2 = ChannelSettings(
354            "link_trigger_channel2", [False] * 4, self,
355            "_apply_trigger_channel2_links")
356
357        self.port = serial.Serial(
358            port_name,
359            baud_rate,
360            timeout=timeout,
361            rtscts=True,
362        )
363        self._closed = False
364        try:
365            # Discard anything left in the buffer by an earlier session
366            self.port.reset_input_buffer()
367            self._handshake()
368            self._read_hardware_info()
369            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
370            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
371            self.stop()
372            self.set_defaults()
373        except BaseException:
374            # Op 81 means something else to other devices, so it is sent
375            # only once the device has identified itself as a Wave Pal
376            self.close(
377                send_disconnect=self.info.firmware_version is not None)
378            raise

Open a connection to a Wave Pal.

Arguments:
  • port_name: USB serial port of the device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • baud_rate: Serial baud rate. USB serial ignores it.
  • timeout: Serial read timeout, in seconds. Loading a long waveform onto a slow microSD card can take a few seconds.
Raises:
  • WavePalError: If the device does not reply to the handshake, runs Pulse Pal firmware, or runs Wave Pal firmware newer than this module supports.
  • serial.SerialException: If the serial port cannot be opened.
port: serial.serialposix.Serial

The open serial.Serial port connected to the device.

info: DeviceInfo

Properties of the connected device. See DeviceInfo.

@staticmethod
def serialportlist(ports_to_list='available'):
380    @staticmethod
381    def serialportlist(ports_to_list="available"):
382        """Return the names of the USB serial ports on this computer.
383
384        Called on the class, without connecting to a device, to find the
385        port name to pass to `WavePalDevice`.
386
387        Args:
388            ports_to_list: `available` to list only the ports that are
389                not already in use, or `all` to list every USB serial
390                port. Not case sensitive.
391
392        Returns:
393            Sorted list of port names, such as `["COM3", "COM7"]`.
394
395        Raises:
396            WavePalError: If `ports_to_list` is not `available` or `all`.
397        """
398        mode = str(ports_to_list).lower()
399        if mode not in ("available", "all"):
400            raise WavePalError(
401                f"Unknown port list type: {ports_to_list}. "
402                "Use 'available' or 'all'."
403            )
404        port_names = []
405        for port_info in serial.tools.list_ports.comports():
406            is_usb = port_info.vid is not None or "USB" in (
407                port_info.hwid or ""
408            ).upper()
409            if not is_usb:
410                continue
411            if mode == "available" and not WavePalDevice._port_is_free(
412                port_info.device
413            ):
414                continue
415            port_names.append(port_info.device)
416        return sorted(port_names)

Return the names of the USB serial ports on this computer.

Called on the class, without connecting to a device, to find the port name to pass to WavePalDevice.

Arguments:
  • ports_to_list: available to list only the ports that are not already in use, or all to list every USB serial port. Not case sensitive.
Returns:

Sorted list of port names, such as ["COM3", "COM7"].

Raises:
  • WavePalError: If ports_to_list is not available or all.
def set_defaults(self):
438    def set_defaults(self):
439        """Program the default settings on the device.
440
441        The defaults are a 10 kHz sampling rate, the -10 V to 10 V output
442        range, loop mode off with loop durations of 0, normal trigger
443        mode, and all output channels linked to trigger channel 1 and not
444        to trigger channel 2. They match the settings the device starts
445        with.
446
447        Loaded waveforms are kept, and loaded again if the output range
448        changes (see `WavePalDevice.output_range`).
449
450        Raises:
451            WavePalError: If a loaded waveform does not fit the default
452                output range, -10 V to 10 V.
453        """
454        self.sampling_rate = 10000
455        self.output_range = "-10V:10V"
456        self.loop_mode = False
457        self.loop_duration = 0
458        self.trigger_mode = "Normal"
459        self._set_trigger_links([True] * 4, [False] * 4)

Program the default settings on the device.

The defaults are a 10 kHz sampling rate, the -10 V to 10 V output range, loop mode off with loop durations of 0, normal trigger mode, and all output channels linked to trigger channel 1 and not to trigger channel 2. They match the settings the device starts with.

Loaded waveforms are kept, and loaded again if the output range changes (see WavePalDevice.output_range).

Raises:
  • WavePalError: If a loaded waveform does not fit the default output range, -10 V to 10 V.
sampling_rate
461    @property
462    def sampling_rate(self):
463        """Sampling rate of all output channels, in Hz.
464
465        A whole number from 1 to `DeviceInfo.max_sampling_rate`. It can be
466        changed during playback. The rate played can differ slightly from
467        the rate set: see `WavePalDevice.actual_sampling_rate`.
468        """
469        return self._sampling_rate

Sampling rate of all output channels, in Hz.

A whole number from 1 to DeviceInfo.max_sampling_rate. It can be changed during playback. The rate played can differ slightly from the rate set: see WavePalDevice.actual_sampling_rate.

actual_sampling_rate
496    @property
497    def actual_sampling_rate(self):
498        """The sampling rate the device plays, in Hz.
499
500        The device divides `DeviceInfo.sample_clock_hz` (24 MHz) by a
501        whole number, so the rate played is the nearest one of those to
502        `WavePalDevice.sampling_rate`. For example, 44100 Hz plays at
503        44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz,
504        25 kHz or 100 kHz, play exactly.
505        """
506        return self._actual_rate(self._sampling_rate)

The sampling rate the device plays, in Hz.

The device divides DeviceInfo.sample_clock_hz (24 MHz) by a whole number, so the rate played is the nearest one of those to WavePalDevice.sampling_rate. For example, 44100 Hz plays at 44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz, 25 kHz or 100 kHz, play exactly.

output_range
508    @property
509    def output_range(self):
510        """Voltage range of all output channels.
511
512        One of `DeviceInfo.output_ranges`: `"0V:5V"`, `"0V:10V"`,
513        `"-5V:5V"` or `"-10V:10V"`. The smallest range that fits the
514        waveforms gives the finest voltage steps.
515
516        Changing the range stops playback, sets all outputs to 0 V, and
517        loads the waveforms again, encoded for the new range. It raises
518        an error, and changes nothing, if a loaded waveform does not fit
519        the new range.
520        """
521        return self._output_range

Voltage range of all output channels.

One of DeviceInfo.output_ranges: "0V:5V", "0V:10V", "-5V:5V" or "-10V:10V". The smallest range that fits the waveforms gives the finest voltage steps.

Changing the range stops playback, sets all outputs to 0 V, and loads the waveforms again, encoded for the new range. It raises an error, and changes nothing, if a loaded waveform does not fit the new range.

loop_mode
551    @property
552    def loop_mode(self):
553        """Whether each output channel loops its waveform.
554
555        Indexed by channel number (see "Channel settings" above). `True`
556        repeats the waveform until `WavePalDevice.loop_duration` has
557        elapsed, or until stopped if the loop duration is 0. `False` plays
558        it once. A change applies to playback in progress.
559        """
560        return self._loop_mode

Whether each output channel loops its waveform.

Indexed by channel number (see "Channel settings" above). True repeats the waveform until WavePalDevice.loop_duration has elapsed, or until stopped if the loop duration is 0. False plays it once. A change applies to playback in progress.

loop_duration
566    @property
567    def loop_duration(self):
568        """How long each output channel loops its waveform, in seconds.
569
570        Indexed by channel number (see "Channel settings" above). Applies
571        in loop mode only, and counts from the trigger. The channel stops
572        when it has elapsed, part way through the waveform if need be. `0`
573        loops until the channel is stopped. Durations are rounded to a
574        whole number of samples, and a nonzero duration lasts at least
575        one sample.
576        """
577        return self._loop_duration

How long each output channel loops its waveform, in seconds.

Indexed by channel number (see "Channel settings" above). Applies in loop mode only, and counts from the trigger. The channel stops when it has elapsed, part way through the waveform if need be. 0 loops until the channel is stopped. Durations are rounded to a whole number of samples, and a nonzero duration lasts at least one sample.

trigger_mode
583    @property
584    def trigger_mode(self):
585        """How each output channel responds to a trigger.
586
587        Indexed by channel number (see "Channel settings" above). A
588        trigger is a rising edge on a linked trigger channel, or a call to
589        `WavePalDevice.play`:
590
591        - `"Normal"`: starts the waveform. Triggers during playback are
592          ignored.
593        - `"Master"`: starts the waveform, or restarts it from the first
594          sample if it is playing.
595        - `"Toggle"`: starts the waveform, or stops it if it is playing.
596        - `"Gated"`: starts the waveform, and a falling edge on the
597          trigger channel stops it, unless the other trigger channel is
598          also linked and still high. The waveform plays while the TTL is
599          high: with loop mode on and a loop duration of 0, it plays for
600          exactly as long.
601
602        Names are not case sensitive.
603        """
604        return self._trigger_mode

How each output channel responds to a trigger.

Indexed by channel number (see "Channel settings" above). A trigger is a rising edge on a linked trigger channel, or a call to WavePalDevice.play:

  • "Normal": starts the waveform. Triggers during playback are ignored.
  • "Master": starts the waveform, or restarts it from the first sample if it is playing.
  • "Toggle": starts the waveform, or stops it if it is playing.
  • "Gated": starts the waveform, and a falling edge on the trigger channel stops it, unless the other trigger channel is also linked and still high. The waveform plays while the TTL is high: with loop mode on and a loop duration of 0, it plays for exactly as long.

Names are not case sensitive.

waveforms
634    @property
635    def waveforms(self):
636        """The waveforms loaded with `WavePalDevice.load_waveform`.
637
638        A five element list indexed by channel number, with index 0
639        unused. Each element is a read-only NumPy array of voltages, or
640        `None` if this object has not loaded a waveform on that channel.
641        `WavePalDevice.status` shows what the device itself holds.
642        """
643        return list(self._waveforms)

The waveforms loaded with WavePalDevice.load_waveform.

A five element list indexed by channel number, with index 0 unused. Each element is a read-only NumPy array of voltages, or None if this object has not loaded a waveform on that channel. WavePalDevice.status shows what the device itself holds.

def load_waveform(self, channel, waveform):
649    def load_waveform(self, channel, waveform):
650        """Load a waveform onto an output channel.
651
652        The samples are written to the device's microSD card, and the
653        first `DeviceInfo.buffer_samples` of them are also kept in its RAM.
654        Loading stops the channel if it is playing. Other channels keep
655        playing.
656
657        ```python
658        t = np.arange(10000) / W.sampling_rate
659        W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t))
660        ```
661
662        Args:
663            channel: Output channel number, 1-4.
664            waveform: Voltages of the samples, played at
665                `WavePalDevice.sampling_rate`. A list, tuple or NumPy
666                array of 1 to `DeviceInfo.max_samples` values, all within
667                `WavePalDevice.output_range`.
668
669        Raises:
670            WavePalError: If the channel or waveform is invalid, or the
671                device could not store the waveform. The channel is then
672                left without a waveform.
673        """
674        channel = self._channel_number(channel)
675        samples = np.array(waveform, dtype=float).ravel()
676        if not 1 <= samples.size <= self.info.max_samples:
677            raise WavePalError(
678                f"A waveform must have 1 to {self.info.max_samples} samples. "
679                f"Received {samples.size}."
680            )
681        codes = self._volts_to_codes(samples)
682        self._waveforms[channel] = None  # The device unloads it first
683        self._write_command(
684            self._OP_LOAD_WAVEFORM,
685            struct.pack("<BI", channel, samples.size) + codes.tobytes(),
686        )
687        self._read_ack("load_waveform()")
688        samples.flags.writeable = False
689        self._waveforms[channel] = samples

Load a waveform onto an output channel.

The samples are written to the device's microSD card, and the first DeviceInfo.buffer_samples of them are also kept in its RAM. Loading stops the channel if it is playing. Other channels keep playing.

t = np.arange(10000) / W.sampling_rate
W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t))
Arguments:
Raises:
  • WavePalError: If the channel or waveform is invalid, or the device could not store the waveform. The channel is then left without a waveform.
def play(self, channels):
691    def play(self, channels):
692        """Trigger output channels in software.
693
694        Each channel responds according to its
695        `WavePalDevice.trigger_mode`, as if a linked trigger channel had
696        gone high. Channels start on the same sample. Channels without a
697        waveform are ignored.
698
699        ```python
700        W.play(1)
701        W.play([2, 4])
702        ```
703
704        Args:
705            channels: Output channel number 1-4, or a list of them.
706        """
707        bits = self._channel_bits(channels)
708        self._write_command(self._OP_PLAY, bytes([bits]))

Trigger output channels in software.

Each channel responds according to its WavePalDevice.trigger_mode, as if a linked trigger channel had gone high. Channels start on the same sample. Channels without a waveform are ignored.

W.play(1)
W.play([2, 4])
Arguments:
  • channels: Output channel number 1-4, or a list of them.
def stop(self, channels=None):
710    def stop(self, channels=None):
711        """Stop playback. The stopped channels output 0 V.
712
713        Args:
714            channels: Output channel number 1-4, or a list of them.
715                `None` stops all channels.
716        """
717        if channels is None:
718            bits = self._ALL_CHANNELS
719        else:
720            bits = self._channel_bits(channels)
721        self._write_command(self._OP_STOP, bytes([bits]))

Stop playback. The stopped channels output 0 V.

Arguments:
  • channels: Output channel number 1-4, or a list of them. None stops all channels.
def set_fixed_voltage(self, channels, voltage):
723    def set_fixed_voltage(self, channels, voltage):
724        """Set output channels to a fixed voltage.
725
726        Stops playback on those channels. The voltage holds until the
727        channel is triggered or stopped.
728
729        Args:
730            channels: Output channel number 1-4, or a list of them.
731            voltage: Voltage, within `WavePalDevice.output_range`.
732
733        Raises:
734            WavePalError: If the voltage is outside the output range, or
735                the device rejects the command.
736        """
737        bits = self._channel_bits(channels)
738        code = int(self._volts_to_codes([voltage])[0])
739        self._write_command(self._OP_SET_FIXED_VOLTAGE,
740                            struct.pack("<BH", bits, code))
741        self._read_ack("set_fixed_voltage()")

Set output channels to a fixed voltage.

Stops playback on those channels. The voltage holds until the channel is triggered or stopped.

Arguments:
Raises:
  • WavePalError: If the voltage is outside the output range, or the device rejects the command.
def status(self):
743    def status(self):
744        """Read the device's playback state.
745
746        Returns:
747            A `DeviceStatus`, with the channels that are playing, the
748            number of samples each channel holds, underrun counts, and
749            the longest playback interrupt.
750        """
751        self._write_command(self._OP_GET_STATUS)
752        values = struct.unpack(
753            self._STATUS_FORMAT,
754            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
755        )
756        playing_bits = values[0]
757        return DeviceStatus(
758            playing=[ch for ch in range(1, 5)
759                     if playing_bits & (1 << (ch - 1))],
760            samples_loaded=[None, *values[1:5]],
761            underruns=[None, *values[5:9]],
762            longest_interrupt_us=values[9] / 1000,
763        )

Read the device's playback state.

Returns:

A DeviceStatus, with the channels that are playing, the number of samples each channel holds, underrun counts, and the longest playback interrupt.

def close(self, send_disconnect=True):
781    def close(self, send_disconnect=True):
782        """Close the connection to the device.
783
784        The device shows its own name on its screen again, in place of
785        "PYTHON Connected". It keeps its settings and waveforms, and
786        playback in progress continues. Safe to call more than once.
787        Called automatically when leaving a `with` block and when the
788        object is garbage collected.
789
790        Args:
791            send_disconnect: If `True`, tell the device that the client
792                is disconnecting before closing the port. Set to `False`
793                when the device may not be a Wave Pal.
794        """
795        if getattr(self, "_closed", True):
796            return
797        self._closed = True
798        try:
799            if send_disconnect and self.port and self.port.is_open:
800                self._write_command(self._OP_DISCONNECT)
801        except Exception:
802            pass  # The port may already be gone, e.g. the cable was unplugged
803        finally:
804            if self.port and self.port.is_open:
805                self.port.close()

Close the connection to the device.

The device shows its own name on its screen again, in place of "PYTHON Connected". It keeps its settings and waveforms, and playback in progress continues. Safe to call more than once. Called automatically when leaving a with block and when the object is garbage collected.

Arguments:
  • send_disconnect: If True, tell the device that the client is disconnecting before closing the port. Set to False when the device may not be a Wave Pal.
def bytes_available(self):
807    def bytes_available(self):
808        """Return the number of bytes waiting in the serial read buffer."""
809        return self.port.in_waiting

Return the number of bytes waiting in the serial read buffer.

@dataclass
class DeviceInfo:
103@dataclass
104class DeviceInfo:
105    """Properties of the connected Wave Pal.
106
107    Populated when `WavePalDevice` connects, and available as
108    `WavePalDevice.info`.
109    """
110
111    firmware_version: int = None
112    """Wave Pal firmware version running on the device."""
113
114    hardware_version: int = None
115    """Pulse Pal hardware version, e.g. `3`."""
116
117    n_channels: int = None
118    """Number of output channels."""
119
120    max_samples: int = None
121    """Maximum number of samples in one waveform."""
122
123    max_sampling_rate: int = None
124    """Highest sampling rate, in Hz."""
125
126    buffer_samples: int = None
127    """Samples per playback buffer.
128
129    The first `buffer_samples` samples of each waveform are kept in the
130    device's RAM, so that playback starts at once. A waveform no longer
131    than this plays without reading the microSD card.
132    """
133
134    sample_clock_hz: int = None
135    """Clock that the sample rate is divided from, in Hz.
136
137    See `WavePalDevice.actual_sampling_rate`.
138    """
139
140    output_ranges: tuple = tuple(OUTPUT_RANGES)
141    """Names of the output ranges, accepted by
142    `WavePalDevice.output_range`."""
143
144    trigger_modes: tuple = TRIGGER_MODES
145    """Names of the trigger modes, accepted by
146    `WavePalDevice.trigger_mode`."""

Properties of the connected Wave Pal.

Populated when WavePalDevice connects, and available as WavePalDevice.info.

DeviceInfo( firmware_version: int = None, hardware_version: int = None, n_channels: int = None, max_samples: int = None, max_sampling_rate: int = None, buffer_samples: int = None, sample_clock_hz: int = None, output_ranges: tuple = ('0V:5V', '0V:10V', '-5V:5V', '-10V:10V'), trigger_modes: tuple = ('Normal', 'Master', 'Toggle', 'Gated'))
firmware_version: int = None

Wave Pal firmware version running on the device.

hardware_version: int = None

Pulse Pal hardware version, e.g. 3.

n_channels: int = None

Number of output channels.

max_samples: int = None

Maximum number of samples in one waveform.

max_sampling_rate: int = None

Highest sampling rate, in Hz.

buffer_samples: int = None

Samples per playback buffer.

The first buffer_samples samples of each waveform are kept in the device's RAM, so that playback starts at once. A waveform no longer than this plays without reading the microSD card.

sample_clock_hz: int = None

Clock that the sample rate is divided from, in Hz.

See WavePalDevice.actual_sampling_rate.

output_ranges: tuple = ('0V:5V', '0V:10V', '-5V:5V', '-10V:10V')

Names of the output ranges, accepted by WavePalDevice.output_range.

trigger_modes: tuple = ('Normal', 'Master', 'Toggle', 'Gated')

Names of the trigger modes, accepted by WavePalDevice.trigger_mode.

@dataclass
class DeviceStatus:
149@dataclass
150class DeviceStatus:
151    """A snapshot of the device's playback state, from
152    `WavePalDevice.status`."""
153
154    playing: list
155    """Numbers of the output channels playing a waveform, e.g. `[1, 3]`."""
156
157    samples_loaded: list
158    """Samples in each channel's waveform, `0` if it has none.
159
160    Indexed by channel number, with index 0 unused. This is what the
161    device holds, which can include waveforms loaded by an earlier
162    connection.
163    """
164
165    underruns: list
166    """Underruns on each channel since the device started, indexed by
167    channel number.
168
169    An underrun is a block of samples that was not read from the microSD
170    card by the time it was due. The output then holds its last value
171    until the block arrives. See "Storage and buffering" in the
172    [Wave Pal protocol](https://github.com/sanworks/PulsePal/blob/develop/Firmware/WavePal/PROTOCOL.md#storage-and-buffering).
173    """
174
175    longest_interrupt_us: float
176    """Longest run of the device's playback interrupt since the previous
177    call to `WavePalDevice.status`, in microseconds.
178
179    It must stay below the sample period, `1e6 / sampling_rate`.
180    """

A snapshot of the device's playback state, from WavePalDevice.status.

DeviceStatus( playing: list, samples_loaded: list, underruns: list, longest_interrupt_us: float)
playing: list

Numbers of the output channels playing a waveform, e.g. [1, 3].

samples_loaded: list

Samples in each channel's waveform, 0 if it has none.

Indexed by channel number, with index 0 unused. This is what the device holds, which can include waveforms loaded by an earlier connection.

underruns: list

Underruns on each channel since the device started, indexed by channel number.

An underrun is a block of samples that was not read from the microSD card by the time it was due. The output then holds its last value until the block arrives. See "Storage and buffering" in the Wave Pal protocol.

longest_interrupt_us: float

Longest run of the device's playback interrupt since the previous call to WavePalDevice.status, in microseconds.

It must stay below the sample period, 1e6 / sampling_rate.

class WavePalError(builtins.Exception):
 93class WavePalError(Exception):
 94    """Raised when Wave Pal communication or configuration fails.
 95
 96    This covers serial reads that time out, short serial writes,
 97    commands the device rejects, and values that are out of range, such
 98    as a voltage outside the output range or a sampling rate the device
 99    cannot play.
100    """

Raised when Wave Pal communication or configuration fails.

This covers serial reads that time out, short serial writes, commands the device rejects, and values that are out of range, such as a voltage outside the output range or a sampling rate the device cannot play.