PulsePal

Python interface for the Pulse Pal open source pulse train generator.

Pulse Pal delivers precisely timed voltage pulse trains on four analog output channels, and can be triggered by TTL logic on two trigger channels or in software. This module configures and triggers the device over its USB serial port.

Everything is accessed through PulsePalDevice. Import it, connect to the device's serial port, program parameters, and trigger, e.g.

from PulsePal import PulsePalDevice

with PulsePalDevice("COM3") as P:
    P.set_output_param("phase1_voltage", 1, 5)
    P.set_output_param("phase1_duration", 1, 0.001)
    P.set_output_param("pulse_train_duration", 1, 2)
    P.trigger(1)

Parameter arrays

Output parameters are exposed as plain Python lists on the device object, one list per parameter. Each list has five elements so that the list index matches the Pulse Pal channel number: index 0 is unused and holds nan, and indices 1 to 4 hold the values for output channels 1-4. PulsePalDevice.trigger_mode follows the same convention with three elements, for trigger channels 1 and 2.

P.phase1_voltage[2] = 7                  # channel 2 only
P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
P.sync_to_device()             # push the edits to the device

Editing these lists changes only the local copy. Call PulsePalDevice.sync_to_device to program the device, or use PulsePalDevice.set_output_param and PulsePalDevice.set_trigger_param, which program a single parameter immediately and keep the local copy in step. set_output_param can also set one parameter on several output channels at once:

P.set_output_param("inter_pulse_interval", [1, 2, 3, 4], 0.2)

Units

Voltages are in volts in the range [-10, 10]. Times are in seconds, and are rounded to the nearest cycle of the device's hardware timer (see DeviceInfo.cycle_frequency); a time exactly halfway between two cycles rounds to the even one, as in the MATLAB and C++ classes, and voltages round to the nearest DAC code the same way. Enumerated parameters are integers, and their meanings are given with each attribute below.

A value the device cannot play raises PulsePalError before anything is sent: a voltage outside [-10, 10], a time that is negative or not a number, or an enumerated value out of range. So does a pulse phase, inter-pulse interval or pulse train duration shorter than DeviceInfo.min_pulse_width_us, and custom pulse times closer together than that, so that a Pulse Pal's trigger channels can detect the shortest pulse its output channels play.

Further reading

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 the [Pulse Pal](https://sites.google.com/site/pulsepalwiki/)
   3open source pulse train generator.
   4
   5Pulse Pal delivers precisely timed voltage pulse trains on four analog
   6output channels, and can be triggered by TTL logic on two trigger
   7channels or in software. This module configures and triggers the device
   8over its USB serial port.
   9
  10Everything is accessed through `PulsePalDevice`. Import it, connect to
  11the device's serial port, program parameters, and trigger, e.g.
  12
  13```python
  14from PulsePal import PulsePalDevice
  15
  16with PulsePalDevice("COM3") as P:
  17    P.set_output_param("phase1_voltage", 1, 5)
  18    P.set_output_param("phase1_duration", 1, 0.001)
  19    P.set_output_param("pulse_train_duration", 1, 2)
  20    P.trigger(1)
  21```
  22
  23## Parameter arrays
  24
  25Output parameters are exposed as plain Python lists on the device
  26object, one list per parameter. Each list has five elements so that the
  27list index matches the Pulse Pal channel number: index 0 is unused and
  28holds `nan`, and indices 1 to 4 hold the values for output channels
  291-4. `PulsePalDevice.trigger_mode` follows the same convention with
  30three elements, for trigger channels 1 and 2.
  31
  32```python
  33P.phase1_voltage[2] = 7                  # channel 2 only
  34P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
  35P.sync_to_device()             # push the edits to the device
  36```
  37
  38Editing these lists changes only the local copy. Call
  39`PulsePalDevice.sync_to_device` to program the device, or use
  40`PulsePalDevice.set_output_param` and
  41`PulsePalDevice.set_trigger_param`, which program a single parameter
  42immediately and keep the local copy in step. `set_output_param` can also
  43set one parameter on several output channels at once:
  44
  45```python
  46P.set_output_param("inter_pulse_interval", [1, 2, 3, 4], 0.2)
  47```
  48
  49## Units
  50
  51Voltages are in volts in the range [-10, 10]. Times are in seconds, and
  52are rounded to the nearest cycle of the device's hardware timer (see
  53`DeviceInfo.cycle_frequency`); a time exactly halfway between two cycles
  54rounds to the even one, as in the MATLAB and C++ classes, and voltages
  55round to the nearest DAC code the same way. Enumerated parameters are integers, and
  56their meanings are given with each attribute below.
  57
  58A value the device cannot play raises `PulsePalError` before anything is
  59sent: a voltage outside [-10, 10], a time that is negative or not a
  60number, or an enumerated value out of range. So does a pulse phase,
  61inter-pulse interval or pulse train duration shorter than
  62`DeviceInfo.min_pulse_width_us`, and custom pulse times closer together
  63than that, so that a Pulse Pal's trigger channels can detect the shortest
  64pulse its output channels play.
  65
  66## Further reading
  67
  68- Parameter guide:
  69  https://sites.google.com/site/pulsepalwiki/parameter-guide
  70- Serial interface and general documentation:
  71  https://sites.google.com/site/pulsepalwiki/
  72- `WavePal`: alternative firmware that makes a Pulse Pal 3 a four
  73  channel waveform player, and its Python class, `WavePal.WavePalDevice`.
  74
  75## License
  76
  77This file is part of the Sanworks PulsePal repository.
  78Copyright (C) Sanworks LLC, Rochester, New York, USA
  79
  80This program is free software: you can redistribute it and/or modify
  81it under the terms of the GNU General Public License as published by
  82the Free Software Foundation, version 3.
  83
  84This program is distributed WITHOUT ANY WARRANTY and without even the
  85implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  86See the GNU General Public License for more details.
  87
  88You should have received a copy of the GNU General Public License
  89along with this program. If not, see <http://www.gnu.org/licenses/>.
  90"""
  91
  92from decimal import Decimal
  93from dataclasses import dataclass
  94import math
  95import numbers
  96import struct
  97import time
  98
  99import numpy as np
 100import serial
 101import serial.tools.list_ports
 102
 103__all__ = ["PulsePalDevice", "DeviceInfo", "PulsePalError"]
 104__docformat__ = "google"
 105
 106
 107class PulsePalError(Exception):
 108    """Raised when Pulse Pal communication or configuration fails.
 109
 110    This covers serial reads that time out, short serial writes, missing
 111    acknowledgement bytes, unknown parameter names, values that do not
 112    fit the datatype expected by the device, and operations that the
 113    connected firmware or hardware revision does not support.
 114    """
 115
 116
 117@dataclass
 118class DeviceInfo:
 119    """Properties of the connected Pulse Pal device.
 120
 121    An instance is created for each connection and populated during the
 122    handshake in `PulsePalDevice.__init__`. It is available as
 123    `PulsePalDevice.info`. Devices running firmware v21 report only a
 124    firmware version, so the remaining fields are filled in with the
 125    known values for Pulse Pal hardware v2.
 126
 127    ```python
 128    print(P.info.firmware_version)
 129    ```
 130    """
 131
 132    output_parameter_names: list = None
 133    """Output parameter names, ordered by parameter code.
 134
 135    The position of a name in this list, plus 1, is the parameter code
 136    the device expects. Any name here is valid as the `param_name`
 137    argument of `PulsePalDevice.set_output_param`, and is also the name
 138    of the matching parameter array attribute on `PulsePalDevice`.
 139    """
 140
 141    trigger_parameter_names: list = None
 142    """Trigger parameter names, accepted by
 143    `PulsePalDevice.set_trigger_param`."""
 144
 145    firmware_version: int = None
 146    """Firmware version running on the connected device."""
 147
 148    hardware_version: int = None
 149    """Hardware revision of the connected device, e.g. `2` or `3`.
 150
 151    Reported by the device on firmware v22 and newer; assumed to be `2`
 152    on older firmware.
 153    """
 154
 155    max_custom_pulses: int = None
 156    """Maximum number of pulses in a single custom pulse train."""
 157
 158    n_custom_pulse_trains: int = None
 159    """Number of custom pulse trains the device can store."""
 160
 161    cycle_frequency: float = None
 162    """Update frequency of the device's hardware timer, in Hz.
 163
 164    All time parameters are rounded to a whole number of these cycles,
 165    so this sets the timing resolution of the device.
 166    """
 167
 168    cycle_period_us: float = None
 169    """Update period of the device's hardware timer, in microseconds."""
 170
 171    min_pulse_width_us: float = None
 172    """Shortest pulse phase, inter-pulse interval and pulse train duration,
 173    and shortest time between custom pulses, in microseconds.
 174
 175    Two timer cycles: a trigger channel reads its input once per cycle, so
 176    a pulse must last two cycles to be detected reliably.
 177    """
 178
 179
 180class PulsePalDevice:
 181    """A class to control a Pulse Pal device on a USB serial port.
 182
 183    Creating an instance opens the serial port, exchanges a handshake
 184    with the device, verifies that its firmware is supported, reads the
 185    device properties into `PulsePalDevice.info`, and programs the
 186    device with the default parameters.
 187
 188    ```python
 189    from PulsePal import PulsePalDevice
 190
 191    P = PulsePalDevice("COM3")
 192    P.set_output_param("phase1_voltage", 1, 5)
 193    P.trigger(1)
 194    P.close()
 195    ```
 196
 197    Here, replace "COM3" with Pulse Pal's USB serial port name.
 198    To view a list of available ports, use
 199    PulsePalDevice.serialportlist()
 200    Pulse Pal's port may not be visible if it is connected to another
 201    instance of PulsePalDevice or an external application. Use
 202    PulsePalDevice.serialportlist('all') to view all ports.
 203    If you see multiple available ports, disconnect Pulse Pal's USB plug
 204    and re-run serialportlist(). Notice which port disappears from the list.
 205
 206    PulsePalDevice is also a context manager, which closes the connection on
 207    exit even if an error is raised:
 208
 209    ```python
 210    with PulsePalDevice("COM3") as P:
 211        P.trigger(1)
 212    ```
 213
 214    The attributes below named after Pulse Pal parameters are the local
 215    copy of the device's program. Each is a five element list indexed by
 216    channel number, with index 0 unused. This way channels are addressed
 217    by the index on the device, e.g. trigger channels 1-2 and output
 218    channels 1-4. Assigning parameters does not update the device until
 219    `PulsePalDevice.sync_to_device` is called;
 220    `PulsePalDevice.set_output_param` programs one parameter right away.
 221    """
 222
 223    port: "serial.Serial"
 224    """The open `serial.Serial` port connected to the device."""
 225
 226    info: DeviceInfo
 227    """Properties of the connected device. See `DeviceInfo`."""
 228
 229    is_biphasic: list
 230    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 231
 232    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 233    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 234    phase 2.
 235    """
 236
 237    phase1_voltage: list
 238    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 239
 240    phase2_voltage: list
 241    """Voltage of the second phase of each pulse, in volts [-10, 10].
 242
 243    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 244    """
 245
 246    resting_voltage: list
 247    """Voltage held between pulses, in volts [-10, 10].
 248
 249    A new resting voltage reaches an idle channel's output at once. A
 250    channel playing a pulse train keeps playing it, and moves to the new
 251    resting voltage at its next transition to rest.
 252    """
 253
 254    phase1_duration: list
 255    """Duration of the first phase of each pulse, in seconds.
 256
 257    At least `DeviceInfo.min_pulse_width_us`.
 258    """
 259
 260    inter_phase_interval: list
 261    """Interval between the two phases of a biphasic pulse, in seconds.
 262
 263    The channel rests at `PulsePalDevice.resting_voltage` during the
 264    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 265    """
 266
 267    phase2_duration: list
 268    """Duration of the second phase of each pulse, in seconds.
 269
 270    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 271    At least `DeviceInfo.min_pulse_width_us`.
 272    """
 273
 274    inter_pulse_interval: list
 275    """Interval from the end of one pulse to the onset of the next, in
 276    seconds. At least `DeviceInfo.min_pulse_width_us`."""
 277
 278    burst_duration: list
 279    """Duration of each burst of pulses, in seconds.
 280
 281    Set to `0` to disable bursts, so that pulses continue for the whole
 282    pulse train. A pulse starts only if it ends before the burst does:
 283    its first phase, or for a biphasic pulse the whole pulse, so that
 284    the end of a burst never cuts off a second phase.
 285    """
 286
 287    inter_burst_interval: list
 288    """Interval between bursts of pulses, in seconds.
 289
 290    The channel rests at `PulsePalDevice.resting_voltage` between
 291    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 292    """
 293
 294    pulse_train_duration: list
 295    """Total duration of the pulse train, in seconds.
 296
 297    At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts
 298    short a monophasic pulse still playing. A biphasic pulse starts only
 299    if it can end by the end of the train, so that it keeps its second
 300    phase.
 301    """
 302
 303    pulse_train_delay: list
 304    """Delay from the trigger to the onset of the pulse train, in
 305    seconds."""
 306
 307    link_trigger_channel1: list
 308    """Whether each output channel is linked to trigger channel 1.
 309
 310    `1` links the output channel to trigger channel 1, `0` unlinks it.
 311    """
 312
 313    link_trigger_channel2: list
 314    """Whether each output channel is linked to trigger channel 2.
 315
 316    `1` links the output channel to trigger channel 2, `0` unlinks it.
 317    """
 318
 319    custom_train_id: list
 320    """Custom pulse train played by each output channel.
 321
 322    `0` plays the parametrically defined train. `1` or higher plays the
 323    matching custom train, previously loaded with
 324    `PulsePalDevice.send_custom_pulse_train` or
 325    `PulsePalDevice.send_custom_waveform`.
 326    """
 327
 328    custom_train_target: list
 329    """What the timestamps of a custom train mark.
 330
 331    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 332    is the onset of a burst of pulses.
 333    """
 334
 335    custom_train_loop: list
 336    """Whether a custom train repeats.
 337
 338    `1` loops the custom train until
 339    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 340    once.
 341    """
 342
 343    playback_mode: list
 344    """Continuous playback mode of parametric pulse trains after being triggered
 345
 346    - `0` plays the pulse train once until pulse_train_duration seconds
 347    - `1` plays the pulse train indefinitely, ignoring pulse_train_duration
 348    
 349    """
 350
 351    trigger_mode: list
 352    """Response of each trigger channel to an incoming TTL pulse.
 353
 354    Three element list indexed by trigger channel, with index 0 unused.
 355    Elements 1 and 2 control the respective channels on the device.
 356    Their values can be:
 357
 358    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 359      during the train are ignored.
 360    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 361    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 362    - `3` (param sync, Pulse Pal 3 only): a TTL rising edge starts and
 363      stops nothing. It loads the parameter set most recently sent by
 364      `PulsePalDevice.sync_to_device`. This is how the next trial's
 365      parameters are sent during the current trial and applied the
 366      instant it starts.
 367
 368    An output channel that is idle at a param sync edge takes its new
 369    parameters in the 50 us timer cycle the edge is detected. One that is
 370    playing a pulse train finishes that train on the parameters it
 371    started with, and takes the new ones the moment it ends, so a train
 372    that runs past the end of a trial keeps one shape throughout and the
 373    next trigger plays a whole train with the new parameters. A channel
 374    in continuous playback mode has no train end, so it keeps its
 375    parameters until something stops it.
 376
 377    While either trigger channel is in param sync mode, **only
 378    `PulsePalDevice.sync_to_device` is held back**.
 379    `PulsePalDevice.set_output_param`, `PulsePalDevice.set_trigger_param`
 380    and every other parameter method still program the device
 381    immediately. So leaving param sync mode means calling
 382    `PulsePalDevice.set_trigger_param`; a trigger mode sent by
 383    `PulsePalDevice.sync_to_device` does not take effect until a sync
 384    edge arrives.
 385
 386    A param sync channel's links to output channels are ignored. To
 387    start a pulse train on the same edge, wire the TTL to the other
 388    trigger channel as well: both edges arrive in the same timer cycle,
 389    and the parameters are loaded first.
 390
 391    Connecting a new `PulsePalDevice` takes both trigger channels out of
 392    param sync mode, so that the default parameters it programs reach the
 393    device instead of waiting for a TTL.
 394
 395    ```python
 396    P.set_trigger_param("trigger_mode", 2, 3)  # channel 2 does param sync
 397    P.phase1_voltage[1:5] = [5] * 4
 398    P.sync_to_device()                         # stored, not yet applied
 399    # ... the next rising edge on trigger channel 2 applies it ...
 400    ```
 401    """
 402
 403    _CURRENT_FIRMWARE_VERSION = 22
 404
 405    _OP_MENU_BYTE = 213
 406    _HANDSHAKE_OPCODE = 72
 407    _HANDSHAKE_RESPONSE = 75
 408    _WAVE_PAL_HANDSHAKE_RESPONSE = 87  # 'W': the device runs Wave Pal firmware
 409    _DAC_BITMAX = 65535
 410    _PARAM_MESSAGE_BYTES = 178  # Length of the parameter set sent by op 93
 411    _OLDEST_FIRMWARE_SUPPORTED = 21
 412
 413    # Parameter names in order of their parameter codes (code = index + 1),
 414    # matching _OUTPUT_PARAMETER_ATTRS and the firmware.
 415    _OUTPUT_PARAMETER_NAMES = (
 416        "is_biphasic",
 417        "phase1_voltage",
 418        "phase2_voltage",
 419        "phase1_duration",
 420        "inter_phase_interval",
 421        "phase2_duration",
 422        "inter_pulse_interval",
 423        "burst_duration",
 424        "inter_burst_interval",
 425        "pulse_train_duration",
 426        "pulse_train_delay",
 427        "link_trigger_channel1",
 428        "link_trigger_channel2",
 429        "custom_train_id",
 430        "custom_train_target",
 431        "custom_train_loop",
 432        "resting_voltage",
 433        "playback_mode",
 434    )
 435    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 436    # Output parameter codes whose values are 0 or 1
 437    _BINARY_PARAMETER_CODES = (1, 12, 13, 15, 16, 18)
 438    # Phase 1 and 2 durations, inter-pulse interval and train duration last
 439    # at least this many timer cycles, as in the MATLAB and C++ classes and
 440    # the joystick menu. A trigger channel reads its input once per cycle, so a
 441    # one cycle pulse from another Pulse Pal could fall between two reads.
 442    _MIN_PULSE_CYCLES = 2
 443    _MIN_PULSE_PARAMETER_CODES = (4, 6, 7, 10)
 444
 445    _OUTPUT_PARAMETER_ATTRS = {
 446        1: "is_biphasic",
 447        2: "phase1_voltage",
 448        3: "phase2_voltage",
 449        4: "phase1_duration",
 450        5: "inter_phase_interval",
 451        6: "phase2_duration",
 452        7: "inter_pulse_interval",
 453        8: "burst_duration",
 454        9: "inter_burst_interval",
 455        10: "pulse_train_duration",
 456        11: "pulse_train_delay",
 457        12: "link_trigger_channel1",
 458        13: "link_trigger_channel2",
 459        14: "custom_train_id",
 460        15: "custom_train_target",
 461        16: "custom_train_loop",
 462        17: "resting_voltage",
 463        18: "playback_mode",
 464    }
 465    _ENDIANNESS = "<"
 466    _STRUCT_FORMATS = {
 467        "uint8": "B",
 468        "int8": "b",
 469        "char": "c",
 470        "uint16": "H",
 471        "int16": "h",
 472        "uint32": "I",
 473        "int32": "i",
 474        "single": "f",
 475        "double": "d",
 476    }
 477    _TYPE_RANGES = {
 478        "uint8": (0, 2**8 - 1),
 479        "int8": (-(2**7), 2**7 - 1),
 480        "uint16": (0, 2**16 - 1),
 481        "int16": (-(2**15), 2**15 - 1),
 482        "uint32": (0, 2**32 - 1),
 483        "int32": (-(2**31), 2**31 - 1),
 484    }
 485
 486    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 487        """Open a connection to a Pulse Pal device.
 488
 489        Opens the serial port, exchanges the handshake, verifies the
 490        firmware version, reads the device properties into
 491        `PulsePalDevice.info`, and programs the device with the default
 492        parameters.
 493
 494        Args:
 495            port_name: USB serial port for the Pulse Pal device, such as
 496                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 497            baud_rate: Serial baud rate.
 498            timeout: Serial read timeout, in seconds.
 499
 500        Raises:
 501            PulsePalError: If the device does not return the expected
 502                handshake, or its firmware is older than v21, or its
 503                firmware is newer than this module supports. The port is
 504                closed again before any error is raised.
 505            serial.SerialException: If the serial port cannot be opened.
 506        """
 507        self.info = DeviceInfo()
 508        self._gui = None
 509        self.port = serial.Serial(
 510            port_name,
 511            baud_rate,
 512            timeout=timeout,
 513            rtscts=True,
 514        )
 515        self._closed = False
 516        try:
 517            self._start_session(port_name)
 518        except BaseException:
 519            # Otherwise the port stays open until the object is garbage
 520            # collected, and a second attempt, or WavePal.WavePalDevice,
 521            # cannot open it. The device is in an unknown state, so it is not
 522            # sent the disconnect op.
 523            self.close(send_disconnect=False)
 524            raise
 525
 526    def _start_session(self, port_name):
 527        """Handshake, check the firmware, and program the defaults."""
 528        self._dac_bit_max = self._to_decimal(0)
 529        self.info.firmware_version = None
 530        self.info.hardware_version = None
 531        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 532        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 533
 534        self._write_serial(
 535            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 536            "uint8",
 537        )
 538        handshake = self._read_serial(1, "uint8")
 539        if handshake == self._WAVE_PAL_HANDSHAKE_RESPONSE:
 540            wave_pal_version = self._read_serial(1, "uint32")
 541            raise PulsePalError(
 542                f"Error: the device on {port_name} runs Wave Pal firmware "
 543                f"(v{wave_pal_version}), not Pulse Pal firmware. To use it as "
 544                "a Pulse Pal, load Pulse Pal firmware onto it (see "
 545                "/Firmware/Readme.txt). To use it as a Wave Pal, connect "
 546                "with WavePal.WavePalDevice."
 547            )
 548        if handshake != self._HANDSHAKE_RESPONSE:
 549            raise PulsePalError(
 550                "Error: incorrect handshake returned. Expected "
 551                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 552            )
 553
 554        firmware_version = self._read_serial(1, "uint32")
 555        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 556            raise PulsePalError(
 557                "Error: Old firmware detected, v"
 558                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 559                "newer is required."
 560            )
 561        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 562            raise PulsePalError(
 563                "Error: Future firmware detected, v"
 564                f"{firmware_version}. Please update PulsePal.py or downgrade "
 565                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 566            )
 567        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 568            print(
 569                "Old firmware detected, v"
 570                f"{firmware_version}. This firmware is supported. Update to v"
 571                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 572            )
 573        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 574        self.info.firmware_version = firmware_version
 575
 576        if self.info.firmware_version > 21:
 577            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 578            self.info.hardware_version = self._read_serial(1, "uint8")
 579            self.info.cycle_period_us = self._read_serial(1, "uint32")
 580            self.info.cycle_frequency = 1 / (
 581                self.info.cycle_period_us / 1000000
 582            )
 583            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 584            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 585        else:
 586            self.info.hardware_version = 2
 587            self.info.cycle_period_us = 50
 588            self.info.cycle_frequency = 20000
 589            self.info.n_custom_pulse_trains = 2
 590            self.info.max_custom_pulses = 5000
 591        self.info.min_pulse_width_us = (
 592            self._MIN_PULSE_CYCLES * self.info.cycle_period_us
 593        )
 594
 595        # Client name op + "PYTHON" in ASCII.
 596        self._write_serial(
 597            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 598            "uint8",
 599        )
 600
 601        self.set_default_params()
 602        if self.info.hardware_version > 2:
 603            # A device left in param sync mode by an earlier session would
 604            # store the sync below instead of running it, leaving the device
 605            # on its old program until a TTL arrived. set_trigger_param is
 606            # not deferred that way, so it takes both trigger channels out of
 607            # param sync mode first. See PulsePalDevice.trigger_mode.
 608            for channel in (1, 2):
 609                self.set_trigger_param("trigger_mode", channel, 0)
 610        self.sync_to_device()
 611
 612    @staticmethod
 613    def serialportlist(ports_to_list="available"):
 614        """Return the names of the USB serial ports on this computer.
 615
 616        Called on the class, without connecting to a device, to find the
 617        port name to pass to `PulsePalDevice`:
 618
 619        ```python
 620        from PulsePal import PulsePalDevice
 621
 622        ports = PulsePalDevice.serialportlist()
 623        P = PulsePalDevice(ports[0])
 624        ```
 625
 626        Args:
 627            ports_to_list: `available` to list only the ports that are
 628                not already in use, or `all` to list every USB serial
 629                port. Not case sensitive.
 630
 631        Returns:
 632            Sorted list of port names, such as `["COM3", "COM7"]` on
 633            Windows or `["/dev/ttyACM0"]` on Linux.
 634
 635        Raises:
 636            PulsePalError: If `ports_to_list` is not `available` or
 637                `all`.
 638        """
 639        if not isinstance(ports_to_list, str):
 640            raise PulsePalError(
 641                "serialportlist() takes 'available' or 'all'."
 642            )
 643        mode = ports_to_list.lower()
 644        if mode not in ("available", "all"):
 645            raise PulsePalError(
 646                f"Unknown port list type: {ports_to_list}. "
 647                "Use 'available' or 'all'."
 648            )
 649
 650        port_names = []
 651        for port_info in serial.tools.list_ports.comports():
 652            is_usb = port_info.vid is not None or "USB" in (
 653                port_info.hwid or ""
 654            ).upper()
 655            if not is_usb:
 656                continue
 657            if mode == "available" and not PulsePalDevice._port_is_free(
 658                port_info.device
 659            ):
 660                continue
 661            port_names.append(port_info.device)
 662        return sorted(port_names)
 663
 664    @staticmethod
 665    def _port_is_free(port_name):
 666        """Return True if the port is not already open in another program."""
 667        port = serial.Serial()
 668        port.port = port_name
 669        # Leaving the control lines low avoids resetting boards that
 670        # reset on DTR while the port is probed.
 671        port.dtr = False
 672        port.rts = False
 673        try:
 674            port.open()
 675        except (serial.SerialException, OSError):
 676            return False
 677        port.close()
 678        return True
 679
 680    def set_default_params(self):
 681        """Reset the local copy of all parameters to their defaults.
 682
 683        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 684        1 second, on all four output channels, linked to trigger channel
 685        1 in normal trigger mode.
 686
 687        This updates only the local copy. Call
 688        `PulsePalDevice.sync_to_device` to program the device with them.
 689        """
 690        nan = float("nan")
 691        self.is_biphasic = [nan, 0, 0, 0, 0]
 692        self.phase1_voltage = [nan, 5, 5, 5, 5]
 693        self.phase2_voltage = [nan, -5, -5, -5, -5]
 694        self.resting_voltage = [nan, 0, 0, 0, 0]
 695        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 696        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 697        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 698        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 699        self.burst_duration = [nan, 0, 0, 0, 0]
 700        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 701        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 702        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 703        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 704        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 705        self.custom_train_id = [nan, 0, 0, 0, 0]
 706        self.custom_train_target = [nan, 0, 0, 0, 0]
 707        self.custom_train_loop = [nan, 0, 0, 0, 0]
 708        self.playback_mode = [nan, 0, 0, 0, 0]
 709        self.trigger_mode = [nan, 0, 0]
 710
 711    def set_voltage(self, channel, voltage):
 712        """Set an output channel to a fixed voltage.
 713
 714        The channel holds the voltage until it is set again or until a
 715        pulse train is triggered on it.
 716
 717        Args:
 718            channel: Output channel number, 1-4.
 719            voltage: Voltage to set, in volts [-10, 10].
 720
 721        Raises:
 722            PulsePalError: If the voltage is outside [-10, 10], or the
 723                device does not acknowledge the command.
 724        """
 725        voltage_bits = self._volts_to_bits(voltage, "voltage")
 726        self._write_serial(
 727            (self._OP_MENU_BYTE, 79, channel),
 728            "uint8",
 729            voltage_bits,
 730            "uint16",
 731        )
 732        self._read_ack("set_fixed_voltage()")
 733
 734    def set_calibration(self, channel, voltage_offset):
 735        """Calibrate the zero code of an output channel.
 736
 737        The offset is added to every voltage the channel produces, to
 738        correct for DAC offset error. It is stored in the device's
 739        EEPROM and reloaded on boot, so it only needs to be set once.
 740
 741        Requires Pulse Pal hardware v3 or newer.
 742
 743        Args:
 744            channel: Output channel number, 1-4.
 745            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 746
 747        Raises:
 748            PulsePalError: If the connected hardware is older than v3,
 749                or the device does not acknowledge the command.
 750            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 751                outside [-0.1, 0.1].
 752        """
 753        if self.info.hardware_version < 3:
 754            raise PulsePalError(
 755                "set_calibration() requires hardware v3 or newer."
 756            )
 757        if channel not in (1, 2, 3, 4):
 758            raise ValueError("channel must be 1, 2, 3 or 4")
 759        if not -0.1 <= voltage_offset <= 0.1:  # Also refuses NaN
 760            raise ValueError(
 761                "voltage_offset for zero code calibration must be in range "
 762                "[-0.1, 0.1]"
 763            )
 764        # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated)
 765        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
 766        self._write_serial(
 767            (self._OP_MENU_BYTE, 96, channel - 1),
 768            "uint8",
 769            voltage_bits,
 770            "int16",
 771        )
 772        self._read_ack("set_calibration()")
 773
 774    def set_output_param(self, param_name, channel, value):
 775        """Program an output channel parameter on the device.
 776
 777        The local copy of the parameter is updated to match, so a later
 778        `PulsePalDevice.sync_to_device` will not undo the change.
 779
 780        ```python
 781        P.set_output_param("is_biphasic", 1, 1)
 782        P.set_output_param("phase1_voltage", 1, 10)
 783        P.set_output_param(3, 1, -10)   # same, by param code
 784        P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3])
 785        P.set_output_param("phase1_duration", [2, 4], 0.002)
 786        ```
 787
 788        Setting all four channels in one call programs them with a single
 789        command (firmware v22 or newer). Otherwise, each listed channel is
 790        programmed with its own command. Channels that are not listed are
 791        not changed.
 792
 793        Args:
 794            param_name: Parameter name, as listed in
 795                `DeviceInfo.output_parameter_names`, or its integer
 796                parameter code.
 797            channel: Output channel number, 1-4, or a list of distinct
 798                output channel numbers.
 799            value: Value to set. Units are volts for voltage parameters,
 800                seconds for time parameters, and integers for enumerated
 801                parameters. See the attributes of `PulsePalDevice` for
 802                the meaning of each parameter. If `channel` is a list,
 803                either one value for all listed channels, or a list with
 804                one value per listed channel, in the same order.
 805
 806        Raises:
 807            PulsePalError: If the parameter name is not recognized, the
 808                channels or number of values are invalid, a value is out
 809                of range for the parameter (nothing is sent), or the device
 810                does not acknowledge the command. When the device refuses a
 811                value, the local copy of the parameter is first read back
 812                from the device, which resets a value it refuses.
 813        """
 814        param_code = self._get_output_param_code(param_name)
 815
 816        if isinstance(channel, numbers.Integral):
 817            channels = [int(channel)]
 818        else:
 819            channels = [int(ch) for ch in self._as_list(channel)]
 820        if (
 821            not channels
 822            or len(set(channels)) != len(channels)
 823            or any(ch not in (1, 2, 3, 4) for ch in channels)
 824        ):
 825            raise PulsePalError(
 826                "channel must be an output channel number, 1-4, or a list "
 827                "of distinct output channel numbers."
 828            )
 829        values = self._as_list(value)
 830        if len(values) == 1:
 831            values = values * len(channels)
 832        if len(values) != len(channels):
 833            raise PulsePalError(
 834                f"{len(values)} values were given for {len(channels)} "
 835                "channels. Give one value, or one value per channel."
 836            )
 837        self._check_output_values(param_code, channels, values)
 838
 839        if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21:
 840            # One op 91 command programs this parameter on all four channels
 841            values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)]
 842            data, datatype = self._encode_output_param(param_code, values_by_channel)
 843            self._write_serial(
 844                (self._OP_MENU_BYTE, 91, param_code),
 845                "uint8",
 846                data,
 847                datatype,
 848            )
 849            self._read_ack(
 850                "set_output_param()",
 851                on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)),
 852            )
 853            for ch, channel_value in zip((1, 2, 3, 4), values_by_channel):
 854                self._set_output_param_value(param_code, ch, channel_value)
 855        else:
 856            for ch, channel_value in zip(channels, values):
 857                self._set_one_output_param(param_code, ch, channel_value)
 858
 859    def set_trigger_param(self, param_name, channel, value):
 860        """Program a single trigger channel parameter on the device.
 861
 862        The local copy of the parameter is updated to match, so a later
 863        `PulsePalDevice.sync_to_device` will not undo the change.
 864
 865        ```python
 866        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 867        ```
 868
 869        Args:
 870            param_name: Parameter name, as listed in
 871                `DeviceInfo.trigger_parameter_names`, or its integer
 872                parameter code.
 873            channel: Trigger channel number, 1-2.
 874            value: Value to set. See `PulsePalDevice.trigger_mode` for
 875                the trigger modes.
 876
 877        Raises:
 878            PulsePalError: If the parameter name is not recognized, the
 879                channel or value is out of range (nothing is sent), or the
 880                device does not acknowledge the command.
 881        """
 882        original_value = value
 883        param_code = self._get_trigger_param_code(param_name)
 884        if param_code == 128:
 885            if not (isinstance(channel, numbers.Integral) and channel in (1, 2)):
 886                raise PulsePalError(
 887                    f"channel must be a trigger channel number, 1 or 2. Received {channel!r}."
 888                )
 889            self._check_trigger_mode(value, channel)
 890
 891        self._write_serial(
 892            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 893            "uint8",
 894        )
 895        self._read_ack(
 896            "program_trigger_channel_param()",
 897            on_refusal=lambda: self._refresh_trigger_mode(channel),
 898        )
 899
 900        if param_code in (1, 128):
 901            self.trigger_mode[channel] = original_value
 902
 903    def sync_to_device(self):
 904        """Program the device with the local copy of all parameters.
 905
 906        Call this after assigning to the parameter array attributes, to
 907        send every output and trigger parameter to the device in a
 908        single transaction.
 909
 910        ```python
 911        P.phase1_voltage[1:5] = [5] * 4
 912        P.sync_to_device()
 913        ```
 914
 915        On Pulse Pal 3, if either trigger channel is in param sync mode
 916        (`PulsePalDevice.trigger_mode` 3), the device stores the
 917        parameters instead of programming them, and loads them on the
 918        next rising edge of that channel. The device still acknowledges
 919        whether every value was in range. This is the only method whose
 920        effect is deferred that way.
 921
 922        Raises:
 923            PulsePalError: If a value is out of range for its parameter
 924                (nothing is sent), or the device does not acknowledge the
 925                command.
 926        """
 927        self._check_program()
 928        # _sync_all_params() for firmware v22+ uses the newer packed sync
 929        # opcode (92). _sync_all_params_legacy() uses the less efficient
 930        # legacy packed sync opcode (73).
 931        if self.info.firmware_version > 21:
 932            self._sync_all_params()
 933        else:
 934            self._sync_all_params_legacy()
 935        self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)
 936
 937    def sync_from_device(self):
 938        """Read all parameters from the device into the local copy.
 939
 940        Overwrites every parameter array attribute with the program
 941        currently stored on the device. Useful after the device has been
 942        reprogrammed from its thumb joystick.
 943
 944        Requires firmware v22 or newer.
 945
 946        Raises:
 947            PulsePalError: If the connected firmware is older than v22,
 948                or the device does not return the full parameter set.
 949        """
 950        self._require_firmware(22, "sync_from_device()")
 951        for attr_name, values in self._read_device_params().items():
 952            setattr(self, attr_name, values)
 953
 954    def _read_device_params(self):
 955        """Read the device's parameters (op 93).
 956
 957        Returns a dict of parameter lists, indexed by channel like the
 958        parameter array attributes. Continuous playback mode is not part
 959        of op 93, so `playback_mode` is not included.
 960        """
 961        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 962        # The device sends the whole parameter set as one message, so read it in one go and
 963        # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md.
 964        message = self._read_raw(self._PARAM_MESSAGE_BYTES)
 965        values = struct.unpack(
 966            f"{self._ENDIANNESS}32I12H26B",
 967            message,
 968        )
 969        params = {}
 970
 971        for index, attr_name in enumerate((
 972                "phase1_duration",
 973                "inter_phase_interval",
 974                "phase2_duration",
 975                "inter_pulse_interval",
 976                "burst_duration",
 977                "inter_burst_interval",
 978                "pulse_train_duration",
 979                "pulse_train_delay",
 980        )):
 981            cycles = values[index * 4:index * 4 + 4]
 982            params[attr_name] = [float("nan")] + [self._cycles_to_seconds(x) for x in cycles]
 983
 984        for index, attr_name in enumerate((
 985                "phase1_voltage",
 986                "phase2_voltage",
 987                "resting_voltage",
 988        )):
 989            bits = values[32 + index * 4:32 + index * 4 + 4]
 990            params[attr_name] = [float("nan")] + [self._bits_to_volts(x) for x in bits]
 991
 992        for index, attr_name in enumerate((
 993                "is_biphasic",
 994                "custom_train_id",
 995                "custom_train_target",
 996                "custom_train_loop",
 997                "link_trigger_channel1",
 998                "link_trigger_channel2",
 999        )):
1000            params[attr_name] = [float("nan")] + list(values[44 + index * 4:44 + index * 4 + 4])
1001        params["trigger_mode"] = [float("nan")] + list(values[68:70])
1002        return params
1003
1004    def send_custom_pulse_train(
1005        self,
1006        custom_train_id,
1007        pulse_times,
1008        pulse_voltages,
1009    ):
1010        """Load a custom pulse train onto the device.
1011
1012        A custom pulse train is an arbitrary list of pulse onset times
1013        and voltages, replacing the parametric pulse voltage and onset timing.
1014        Set `PulsePalDevice.custom_train_id` on an output channel to play
1015        the train there.
1016
1017        ```python
1018        P.send_custom_pulse_train(
1019            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1020        )
1021        P.set_output_param("custom_train_id", 1, 2)
1022        ```
1023
1024        Args:
1025            custom_train_id: Custom train to load, from 1 to
1026                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1027                4 on Pulse Pal 3).
1028            pulse_times: Pulse onset times, in seconds, relative to the
1029                start of the train. Accepts a list, tuple or NumPy
1030                array.
1031            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
1032                Must be the same length as `pulse_times`.
1033
1034        Raises:
1035            PulsePalError: If `custom_train_id` is out of range,
1036                `pulse_times` and `pulse_voltages` differ in length,
1037                there are more pulses than
1038                `DeviceInfo.max_custom_pulses`, a pulse time is less
1039                than `DeviceInfo.min_pulse_width_us` after the one before
1040                it (after rounding to the device's timer cycle), or the
1041                device does not acknowledge the command.
1042        """
1043        pulse_times = self._as_list(pulse_times)
1044        pulse_voltages = self._as_list(pulse_voltages)
1045        n_pulses = len(pulse_times)
1046        if n_pulses != len(pulse_voltages):
1047            raise PulsePalError(
1048                "pulse_times and pulse_voltages must be the same length."
1049            )
1050
1051        pulse_times_cycles = [
1052            self._seconds_to_cycles(pulse_time, "pulse_times")
1053            for pulse_time in pulse_times
1054        ]
1055        pulse_voltage_bits = [
1056            self._volts_to_bits(voltage, "pulse_voltages")
1057            for voltage in pulse_voltages
1058        ]
1059
1060        self._send_custom_train(
1061            custom_train_id,
1062            pulse_times_cycles,
1063            pulse_voltage_bits,
1064            "send_custom_pulse_train()",
1065        )
1066
1067    def send_custom_waveform(
1068        self,
1069        custom_train_id,
1070        pulse_width,
1071        pulse_voltages,
1072    ):
1073        """Load an arbitrary waveform onto the device.
1074
1075        A convenience shorthand for
1076        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1077        confluent pulses, so that `pulse_voltages` is played as a
1078        waveform sampled every `pulse_width` seconds.
1079
1080        Set the channel's `PulsePalDevice.phase1_duration` to
1081        `pulse_width` as well, so that each sample is held for the
1082        sampling period.
1083
1084        ```python
1085        import math
1086
1087        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1088        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1089        P.set_output_param("custom_train_id", 2, 1)
1090        P.set_output_param("phase1_duration", 2, 0.001)
1091        ```
1092
1093        Args:
1094            custom_train_id: Custom train to load, from 1 to
1095                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1096                4 on Pulse Pal 3).
1097            pulse_width: Sampling period, in seconds. Each voltage is
1098                held for this long.
1099            pulse_voltages: Waveform samples, in volts [-10, 10].
1100                Accepts a list, tuple or NumPy array.
1101
1102        Raises:
1103            PulsePalError: If `custom_train_id` is out of range, there
1104                are more samples than `DeviceInfo.max_custom_pulses`,
1105                `pulse_width` rounds to less than
1106                `DeviceInfo.min_pulse_width_us`, or the device does not
1107                acknowledge the command.
1108        """
1109        pulse_voltages = self._as_list(pulse_voltages)
1110        n_pulses = len(pulse_voltages)
1111        pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width")
1112        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
1113        pulse_voltage_bits = [
1114            self._volts_to_bits(voltage, "pulse_voltages")
1115            for voltage in pulse_voltages
1116        ]
1117
1118        self._send_custom_train(
1119            custom_train_id,
1120            pulse_times,
1121            pulse_voltage_bits,
1122            "send_custom_waveform()",
1123        )
1124
1125    def trigger(
1126        self,
1127        channel1=None,
1128        channel2=None,
1129        channel3=None,
1130        channel4=None,
1131    ):
1132        """Trigger output channels in software.
1133
1134        Triggered channels start their pulse trains together. Three
1135        calling conventions are accepted:
1136
1137        ```python
1138        P.trigger(1, 0, 1, 0)   # one flag per channel
1139        P.trigger(3)            # a single channel number
1140        P.trigger([1, 4])       # several channel numbers
1141        ```
1142
1143        Channel numbers may be NumPy integers, and a list of them may be a
1144        NumPy array. A channel that is already playing a pulse train
1145        ignores the trigger, and `stop()` cancels a trigger that has not
1146        started its channel yet.
1147
1148        Args:
1149            channel1: `1` to trigger channel 1, otherwise `0`; or, when
1150                it is the only argument given, a single channel number
1151                or a list of channel numbers to trigger.
1152            channel2: `1` to trigger channel 2, otherwise `0`.
1153            channel3: `1` to trigger channel 3, otherwise `0`.
1154            channel4: `1` to trigger channel 4, otherwise `0`.
1155
1156        Raises:
1157            PulsePalError: If a channel number is not 1-4, or a flag is not
1158                0 or 1. Nothing is triggered.
1159        """
1160        trigger_byte = 0
1161
1162        # Options 2 & 3: Only one argument was provided
1163        if channel2 is None and channel3 is None and channel4 is None:
1164            # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.)
1165            for ch in self._output_channel_numbers(channel1, "trigger()"):
1166                trigger_byte |= (1 << (ch - 1))
1167
1168        # Option 1: Original input scheme (logicals for each channel)
1169        else:
1170            # Fallback to 0 if an argument was omitted via kwargs
1171            flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)]
1172            for bit, flag in enumerate(flags):
1173                if not (isinstance(flag, numbers.Integral) and flag in (0, 1)):
1174                    raise PulsePalError(
1175                        "trigger(): with more than one argument, each is a flag for one "
1176                        f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}."
1177                    )
1178                trigger_byte |= int(flag) << bit
1179
1180        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
1181
1182    def sd_settings(self, settings_file_name, op):
1183        """Save, load, or delete a settings file on the microSD card.
1184
1185        A settings file holds a complete Pulse Pal program, so that it
1186        can be recalled later from software or from the device's front
1187        panel. Loading a file also refreshes the local copy of the
1188        parameters, via `PulsePalDevice.sync_from_device`.
1189
1190        ```python
1191        P.sd_settings("MyProtocol.pps", "save")
1192        ```
1193
1194        Args:
1195            settings_file_name: Settings file name, at most 15 ASCII
1196                characters including the required `.pps` extension.
1197            op: `"save"`, `"load"`, or `"delete"`.
1198
1199        Raises:
1200            PulsePalError: If the file name has no `.pps` extension or
1201                is too long, `op` is not one of the three operations, or
1202                the device does not acknowledge the command. A load that
1203                fails leaves the device on its own default parameters
1204                (not the ones `set_default_params` sets), and the local
1205                copy is read back from the device before the error is
1206                raised.
1207        """
1208        if ".pps" not in settings_file_name:
1209            raise PulsePalError(
1210                "Error: The file name must have a valid .pps extension."
1211            )
1212        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1213        try:
1214            op_byte = op_byte_by_name[str(op).lower()]
1215        except KeyError as exc:
1216            raise PulsePalError(
1217                "File op must be: 'save', 'load' or 'delete'."
1218            ) from exc
1219
1220        filename_bytes = settings_file_name.encode("ascii")
1221        if len(filename_bytes) > 15:
1222            raise PulsePalError("settings_file_name is too long.")
1223        self._write_serial(
1224            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1225            "uint8",
1226            list(filename_bytes),
1227            "uint8",
1228        )
1229        if self.info.firmware_version > 21:
1230            # Sent after the file operation has finished. A refused load leaves the device on
1231            # its default parameters, so the local copy is read back first.
1232            self._read_ack(
1233                "sd_settings()",
1234                on_refusal=self.sync_from_device if op_byte == 2 else None,
1235            )
1236        elif op_byte == 2:
1237            time.sleep(0.1)  # Firmware v21 does not acknowledge, so allow time for the load
1238        if op_byte == 2:
1239            self.sync_from_device()
1240
1241    def stop(self, channels=None):
1242        """Stop pulse trains currently playing on the device.
1243
1244        Every output channel returns to its
1245        `PulsePalDevice.resting_voltage`.
1246
1247        Args:
1248            channels (list or tuple, optional): A list of channels to stop, e.g.,
1249                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single
1250                channel number. NumPy integers and arrays are accepted. Default is
1251                None, which stops all channels. (Requires firmware v22+)
1252
1253        Raises:
1254            PulsePalError: If a channel number is not 1-4.
1255        """
1256        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1257
1258        if channels is not None:
1259            if self.info.firmware_version < 22:
1260                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1261
1262            bit_code = 0
1263            for ch in self._output_channel_numbers(channels, "stop()"):
1264                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1265                bit_code |= 1 << (ch - 1)
1266
1267        # Send
1268        if self.info.firmware_version < 22:
1269            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1270        else:
1271            self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")
1272
1273    def format_microsd(self, timeout=30):
1274        """Format the device's microSD card.
1275
1276        Erases every settings file stored on the device and resets its
1277        parameters to the defaults. The user is prompted at the console
1278        to confirm before anything is erased.
1279
1280        Requires Pulse Pal hardware v3 or newer.
1281
1282        Args:
1283            timeout: Seconds to wait for the device to report that
1284                formatting has finished.
1285
1286        Returns:
1287            `None` once the card has been formatted, or an empty string
1288            if the user declines the confirmation prompt.
1289
1290        Raises:
1291            PulsePalError: If the connected hardware is older than v3, if
1292                the device reports that formatting failed, or if it does
1293                not report a result within `timeout` seconds.
1294        """
1295        if self.info.hardware_version < 3:
1296            raise PulsePalError(
1297                "format_microsd() requires hardware v3 or newer."
1298            )
1299
1300        print("*** Pulse Pal microSD Formatter ***")
1301        print("This will format Pulse Pal's microSD card,")
1302        print("erase all settings files on the device")
1303        print("and reset all parameters to defaults.")
1304
1305        reply = input("Do you want to continue (y/n)")
1306
1307        if reply.strip().lower() != "y":
1308            print("Choice confirmed - microSD Card NOT formatted.")
1309            return ""
1310
1311        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1312
1313        # The device replies with lines of status text, the last of which
1314        # contains "!", and then a confirm byte (1 if the card was formatted,
1315        # 0 if not). The confirm byte is sent after the device has reloaded
1316        # its default parameters, so it can arrive well after the text. It
1317        # must be read here: left in the buffer, it would be taken as the
1318        # reply to the next command, and every reply after that would be
1319        # read one byte late.
1320        start = time.time()
1321        message = bytearray()
1322        flag_index = -1
1323        line_end = -1
1324
1325        while time.time() - start < timeout:
1326            n_waiting = self.bytes_available()
1327            if n_waiting:
1328                message.extend(self.port.read(n_waiting))
1329                flag_index = message.find(b"!")
1330                if flag_index >= 0:
1331                    line_end = message.find(b"\n", flag_index)
1332                if line_end >= 0 and len(message) > line_end + 1:
1333                    break
1334            time.sleep(0.01)
1335        if line_end < 0 or len(message) <= line_end + 1:
1336            raise PulsePalError(
1337                "Error: Pulse Pal did not report the result of formatting "
1338                f"its microSD card within {timeout} s."
1339            )
1340        confirm = message[line_end + 1]
1341
1342        text = bytes(message[:flag_index]).decode(
1343            "ascii", errors="replace"
1344        ).rstrip()
1345        if text:
1346            print(text)
1347
1348        self.set_default_params()
1349        if confirm != 1:
1350            raise PulsePalError(
1351                "Error: Pulse Pal could not format its microSD card."
1352            )
1353        return None
1354
1355    def gui(self, block=None, theme=None):
1356        """Open the Pulse Pal parameter GUI, or focus an open one.
1357
1358        The GUI edits its own copy of the parameters, and loads them to
1359        the device when its 'Load to Device' button is clicked. The
1360        window closes automatically when the device is closed or
1361        deleted.
1362
1363        Calling this while the GUI is already open focuses the existing
1364        window rather than opening a second one.
1365
1366        Args:
1367            block: If `True`, the call returns when the GUI is closed. If
1368                `False`, the call returns immediately, and the host
1369                application must run the Tk event loop. If `None`, the
1370                GUI blocks only when the host does not already provide a
1371                Tk event loop, e.g. when launched from a script.
1372            theme: `"light"` or `"dark"` to select the color theme, or
1373                `None` to match the desktop theme. Passing a theme to an
1374                already-open GUI recolors it in place.
1375
1376        Returns:
1377            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1378
1379        Raises:
1380            ValueError: If the theme name is not recognized.
1381        """
1382        gui = getattr(self, "_gui", None)
1383        if gui is not None and not gui.is_closed:
1384            if theme is not None:
1385                gui.set_theme(theme)
1386            gui.focus()
1387            return gui
1388
1389        try:
1390            from .PulsePalGUI import PulsePalGUI
1391        except ImportError:
1392            from PulsePalGUI import PulsePalGUI
1393
1394        gui = PulsePalGUI(self, theme=theme)
1395        self._gui = gui
1396        gui.start(block=block)
1397        return gui
1398
1399    def close(self, send_disconnect=True):
1400        """Close the connection to the device, and the GUI if open.
1401
1402        Safe to call more than once; later calls do nothing. Called
1403        automatically when leaving a `with` block and when the object is
1404        garbage collected.
1405
1406        Args:
1407            send_disconnect: If `True`, tell the device that the client
1408                is disconnecting before closing the port. Set to `False`
1409                when the device is in an unknown state, such as after a
1410                failed handshake.
1411        """
1412        gui = getattr(self, "_gui", None)
1413        self._gui = None
1414        if gui is not None:
1415            try:
1416                gui.close()
1417            except Exception:
1418                # Cleanup must not raise; Tk may already be torn down
1419                pass
1420
1421        if getattr(self, "_closed", True):
1422            return
1423
1424        try:
1425            if send_disconnect and self.port and self.port.is_open:
1426                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1427        finally:
1428            if self.port and self.port.is_open:
1429                self.port.close()
1430            self._closed = True
1431
1432    def bytes_available(self):
1433        """Return the number of bytes waiting in the serial read buffer.
1434
1435        Returns:
1436            Count of bytes that can be read without blocking.
1437        """
1438        return self.port.in_waiting
1439
1440    def _send_custom_train(self, custom_train_id, pulse_times_cycles,
1441                           pulse_voltage_bits, context):
1442        """Send a custom pulse train, already converted to device units.
1443
1444        Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76
1445        (trains 1 and 2 only) on firmware v21.
1446        """
1447        n_trains = self.info.n_custom_pulse_trains
1448        try:
1449            train_id = int(custom_train_id)
1450        except (TypeError, ValueError):
1451            train_id = None
1452        if (
1453            train_id is None
1454            or train_id != custom_train_id
1455            or not 1 <= train_id <= n_trains
1456        ):
1457            raise PulsePalError(
1458                f"{context}: custom_train_id must be an integer from 1 to "
1459                f"{n_trains}. Received {custom_train_id}."
1460            )
1461        n_pulses = len(pulse_times_cycles)
1462        if n_pulses > self.info.max_custom_pulses:
1463            raise PulsePalError(
1464                f"{context}: {n_pulses} pulses were given. Pulse Pal can "
1465                f"store up to {self.info.max_custom_pulses} pulses per "
1466                "custom train."
1467            )
1468        # The device plays each pulse until the next one's time, so a time
1469        # that is not later than the one before it would freeze the output
1470        # for the rest of the train, and one only a cycle later would play
1471        # a pulse too short for a trigger channel to detect (see
1472        # _MIN_PULSE_CYCLES). The check is on the times the device will
1473        # receive, after rounding to its timer cycles.
1474        for i in range(1, n_pulses):
1475            if (pulse_times_cycles[i] - pulse_times_cycles[i - 1]
1476                    < self._MIN_PULSE_CYCLES):
1477                raise PulsePalError(
1478                    f"{context}: pulse times must increase, by at least "
1479                    f"{self._min_pulse_seconds()} s ({self._MIN_PULSE_CYCLES} "
1480                    f"cycles of the device's {self.info.cycle_period_us} us "
1481                    f"timer). Pulse {i + 1} is at "
1482                    f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, "
1483                    f"and pulse {i} is at "
1484                    f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s."
1485                )
1486
1487        if self.info.firmware_version > 21:
1488            header = (self._OP_MENU_BYTE, 95, train_id - 1)
1489        else:
1490            header = (self._OP_MENU_BYTE, 74 + train_id)
1491        self._write_serial(
1492            header,
1493            "uint8",
1494            n_pulses,
1495            "uint32",
1496            pulse_times_cycles,
1497            "uint32",
1498            pulse_voltage_bits,
1499            "uint16",
1500        )
1501        self._read_ack(context)
1502
1503    def _get_output_param_code(self, param_name):
1504        """Resolve an output parameter name or code to its code."""
1505        if isinstance(param_name, str):
1506            try:
1507                return self.info.output_parameter_names.index(param_name) + 1
1508            except ValueError as exc:
1509                raise PulsePalError(
1510                    f"Unknown output parameter: {param_name}."
1511                ) from exc
1512        return int(param_name)
1513
1514    def _get_trigger_param_code(self, param_name):
1515        """Resolve a trigger parameter name or code to its code."""
1516        if isinstance(param_name, str):
1517            try:
1518                index = self.info.trigger_parameter_names.index(param_name)
1519                return index + 128
1520            except ValueError as exc:
1521                raise PulsePalError(
1522                    f"Unknown trigger parameter: {param_name}."
1523                ) from exc
1524        return int(param_name)
1525
1526    def _write_serial(self, *args):
1527        """Write one or more data/type pairs to the serial port."""
1528        if len(args) % 2 != 0:
1529            raise PulsePalError(
1530                "Serial writes require data/type argument pairs."
1531            )
1532
1533        payload = bytearray()
1534        for i in range(0, len(args), 2):
1535            payload.extend(self._pack_values(args[i], args[i + 1]))
1536
1537        bytes_written = self.port.write(bytes(payload))
1538        if bytes_written != len(payload):
1539            raise PulsePalError(
1540                f"Error: wrote {bytes_written} byte(s), expected to write "
1541                f"{len(payload)} byte(s)."
1542            )
1543
1544    def _read_raw(self, n_bytes):
1545        """Read exactly n_bytes from the serial port."""
1546        message_bytes = self.port.read(n_bytes)
1547        if len(message_bytes) < n_bytes:
1548            raise PulsePalError(
1549                f"Error: serial port timed out. "
1550                f"{len(message_bytes)} byte(s) read. "
1551                f"Expected {n_bytes} byte(s)."
1552            )
1553        return message_bytes
1554
1555    def _read_serial(self, n_values, datatype):
1556        """Read values from the serial port and unpack them with struct."""
1557        datatype = self._normalize_datatype(datatype)
1558        fmt = self._STRUCT_FORMATS[datatype]
1559        n_values = int(n_values)
1560        n_bytes = n_values * struct.calcsize(fmt)
1561        message_bytes = self._read_raw(n_bytes)
1562
1563        values = struct.unpack(
1564            f"{self._ENDIANNESS}{n_values}{fmt}",
1565            message_bytes,
1566        )
1567        if n_values == 1:
1568            return values[0]
1569        return list(values)
1570
1571    def _read_ack(self, context, on_refusal=None):
1572        """Read a one-byte acknowledgement from the device.
1573
1574        The device replies 1 if it executed the command, or 0 if it rejected
1575        the command because a value was out of range. On a 0, on_refusal()
1576        is called before the error is raised: the device resets a value it
1577        refuses, so callers use it to read the value back into the local copy.
1578        """
1579        try:
1580            acknowledgement = self._read_serial(1, "uint8")
1581        except PulsePalError as exc:
1582            raise PulsePalError(
1583                "Error: Pulse Pal did not return an acknowledgement byte "
1584                f"after a call to {context}."
1585            ) from exc
1586        if acknowledgement != 1:
1587            refusal = PulsePalError(
1588                f"Error: Pulse Pal rejected the command sent by {context}. "
1589                "This usually means that a channel number, parameter code or "
1590                "value was out of range for the connected device."
1591            )
1592            if on_refusal is not None:
1593                try:
1594                    on_refusal()
1595                except PulsePalError as exc:
1596                    raise refusal from exc
1597            raise refusal
1598
1599    def _pack_values(self, values, datatype):
1600        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1601        datatype = self._normalize_datatype(datatype)
1602        fmt = self._STRUCT_FORMATS[datatype]
1603        values_list = self._as_list(values)
1604
1605        if datatype == "char":
1606            values_list = self._normalize_char_values(values_list)
1607        elif datatype in self._TYPE_RANGES:
1608            values_list = self._normalize_int_values(values_list, datatype)
1609        else:
1610            values_list = [float(value) for value in values_list]
1611
1612        return struct.pack(
1613            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1614            *values_list,
1615        )
1616
1617    def _normalize_datatype(self, datatype):
1618        """Return the datatype name, rejecting unsupported types."""
1619        datatype = str(datatype)
1620        if datatype not in self._STRUCT_FORMATS:
1621            raise PulsePalError(
1622                f"Error: {datatype} is not a data type supported by "
1623                "PulsePalObject."
1624            )
1625        return datatype
1626
1627    def _normalize_char_values(self, values):
1628        """Coerce str, int and bytes values to single ASCII bytes."""
1629        normalized = []
1630        for value in values:
1631            if isinstance(value, str):
1632                value = value.encode("ascii")
1633            if isinstance(value, int):
1634                value = bytes((value,))
1635            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1636                raise PulsePalError(
1637                    "char values must be one-byte bytes, chars, or "
1638                    "integers."
1639                )
1640            normalized.append(bytes(value))
1641        return normalized
1642
1643    def _normalize_int_values(self, values, datatype):
1644        """Coerce values to ints, rejecting any out of range."""
1645        min_value, max_value = self._TYPE_RANGES[datatype]
1646        normalized = []
1647        for value in values:
1648            value = int(value)
1649            if not min_value <= value <= max_value:
1650                raise PulsePalError(
1651                    f"Value {value} is out of range for {datatype} "
1652                    f"({min_value} to {max_value})."
1653                )
1654            normalized.append(value)
1655        return normalized
1656
1657    def _as_list(self, values):
1658        """Return values as a flat list, wrapping scalars in one."""
1659        if isinstance(values, np.ndarray):
1660            return values.ravel().tolist()
1661        if isinstance(values, (bytes, bytearray, str)):
1662            return [values]
1663        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1664            return [values]
1665        try:
1666            return list(values)
1667        except TypeError:
1668            return [values]
1669
1670    def _set_one_output_param(self, param_code, channel, value):
1671        """Program one output parameter on one channel (op 74)."""
1672        data, datatype = self._encode_output_param(param_code, value)
1673        self._write_serial(
1674            (self._OP_MENU_BYTE, 74, param_code, channel),
1675            "uint8",
1676            data,
1677            datatype,
1678        )
1679        self._read_ack(
1680            "program_output_channel_param()",
1681            on_refusal=lambda: self._refresh_output_param(param_code, (channel,)),
1682        )
1683        self._set_output_param_value(param_code, channel, value)
1684
1685    def _encode_output_param(self, param_code, values):
1686        """Convert output parameter values to device units.
1687
1688        Returns the converted values and the datatype the device reads for
1689        this parameter code: DAC bits for voltages, hardware timer cycles
1690        for times, and bytes for everything else.
1691        """
1692        values = self._as_list(values)
1693        name = self._OUTPUT_PARAMETER_ATTRS.get(param_code, f"parameter {param_code}")
1694        if param_code in (2, 3, 17):
1695            return [self._volts_to_bits(v, name) for v in values], "uint16"
1696        if 4 <= param_code <= 11:
1697            return [self._seconds_to_cycles(v, name) for v in values], "uint32"
1698        return values, "uint8"
1699
1700    def _check_output_values(self, param_code, channels, values):
1701        """Raise PulsePalError, before anything is sent, for values the device cannot play.
1702
1703        The device refuses most of them too, but it also resets them (see validateOutputParams()
1704        in /Firmware/PulsePal3/USBOps.ino), so the local copy would describe a different program.
1705        """
1706        for ch, value in zip(channels, values):
1707            self._check_output_value(param_code, value, ch)
1708
1709    def _check_program(self):
1710        """Check the whole local copy before sync_to_device() sends it. See _check_output_values()."""
1711        for param_code, attr_name in self._OUTPUT_PARAMETER_ATTRS.items():
1712            values = getattr(self, attr_name)
1713            for ch in range(1, 5):
1714                self._check_output_value(param_code, values[ch], ch)
1715        for ch in (1, 2):
1716            self._check_trigger_mode(self.trigger_mode[ch], ch)
1717
1718    def _check_output_value(self, param_code, value, channel):
1719        """Raise PulsePalError if a value can never be played for this output parameter."""
1720        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1721        if attr_name is None:
1722            return  # The device refuses a code it does not know
1723        name = f"{attr_name} on channel {channel}"
1724        if param_code in (2, 3, 17):
1725            self._volts_to_bits(value, name)
1726        elif 4 <= param_code <= 11:
1727            cycles = self._seconds_to_cycles(value, name)
1728            # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001,
1729            # but is exactly 2 cycles of 50 us
1730            if cycles < self._MIN_PULSE_CYCLES and param_code in self._MIN_PULSE_PARAMETER_CODES:
1731                raise PulsePalError(
1732                    f"{name} must be at least {self._min_pulse_seconds()} s "
1733                    f"({self._MIN_PULSE_CYCLES} cycles of the device's {self.info.cycle_period_us} us "
1734                    f"timer), so that trigger channels can detect the pulses. Received {value!r}."
1735                )
1736        elif param_code == 14:
1737            self._check_whole_number(value, name, 0, self.info.n_custom_pulse_trains)
1738        elif param_code in self._BINARY_PARAMETER_CODES:
1739            self._check_whole_number(value, name, 0, 1)
1740
1741    def _min_pulse_seconds(self):
1742        """The shortest pulse phase, interval or train, in seconds. See _MIN_PULSE_CYCLES."""
1743        return self._cycles_to_seconds(self._MIN_PULSE_CYCLES)
1744
1745    def _check_trigger_mode(self, value, channel):
1746        """Raise PulsePalError if value is not a trigger mode the device has."""
1747        # Param sync mode (3) is on Pulse Pal 3 with firmware v22 or newer
1748        param_sync = (self.info.hardware_version or 2) > 2 and self.info.firmware_version > 21
1749        self._check_whole_number(
1750            value, f"trigger_mode on trigger channel {channel}", 0, 3 if param_sync else 2
1751        )
1752
1753    def _check_whole_number(self, value, name, low, high):
1754        """Raise PulsePalError unless value is a whole number from low to high."""
1755        valid = (
1756            isinstance(value, numbers.Real)
1757            and math.isfinite(value)
1758            and value == int(value)
1759            and low <= value <= high
1760        )
1761        if not valid:
1762            raise PulsePalError(
1763                f"{name} must be a whole number from {low} to {high}. Received {value!r}."
1764            )
1765
1766    def _refresh_output_param(self, param_code, channels):
1767        """Read an output parameter back from the device into the local copy, for these channels.
1768
1769        Called after the device refused a value for them, which it resets. The parameter's other
1770        channels, and other parameters, keep their local values, which may hold edits not yet synced.
1771        """
1772        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1773        if attr_name is None or self.info.firmware_version < 22:
1774            return  # Nothing local to correct, or no op 93 to read it with
1775        device_values = self._read_device_params().get(attr_name)
1776        if device_values is None:
1777            return  # Not in op 93 (continuous playback mode)
1778        local_values = getattr(self, attr_name)
1779        for ch in channels:
1780            local_values[ch] = device_values[ch]
1781
1782    def _refresh_trigger_mode(self, channel):
1783        """Read a trigger channel's mode back from the device, after the device refused a new one."""
1784        if self.info.firmware_version > 21 and channel in (1, 2):
1785            self.trigger_mode[channel] = self._read_device_params()["trigger_mode"][channel]
1786
1787    def _refresh_after_refused_sync(self):
1788        """Read the parameters back after the device refused a sync_to_device() set.
1789
1790        The device programs the set with the refused values reset, so the local copy is read back.
1791        In param sync mode the device stores the set instead, and op 93 would return the parameters
1792        playing now, so the local copy is left as it is.
1793        """
1794        if self.info.firmware_version > 21 and 3 not in self.trigger_mode[1:3]:
1795            self.sync_from_device()
1796
1797    def _output_channel_numbers(self, channels, context):
1798        """Return output channel numbers as a list of ints.
1799
1800        channels is one integer (NumPy integers included) or an iterable of them (NumPy arrays
1801        included). Raises PulsePalError for anything else, or a channel that is not 1-4.
1802        """
1803        channel_list = self._as_list(channels)
1804        if not all(isinstance(ch, numbers.Integral) and 1 <= ch <= 4 for ch in channel_list):
1805            raise PulsePalError(
1806                f"{context}: channels must be output channel numbers 1-4, as one integer or a "
1807                f"list of them. Received {channels!r}."
1808            )
1809        return [int(ch) for ch in channel_list]
1810
1811    def _set_output_param_value(self, param_code, channel, original_value):
1812        """Store a programmed value in its local parameter array."""
1813        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1814        if attr_name is None:
1815            return
1816        values = getattr(self, attr_name)
1817        values[channel] = original_value
1818
1819    def _to_decimal(self, value):
1820        """Convert a value to a Decimal with PulsePal precision."""
1821        return Decimal(value).quantize(Decimal("1.0000"))
1822
1823    def _volts_to_bits(self, value, name="voltage"):
1824        """Convert -10 V to +10 V to the corresponding DAC bit value.
1825
1826        Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do.
1827        Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would
1828        play a different voltage.
1829        """
1830        try:
1831            volts = float(value)
1832        except (TypeError, ValueError):
1833            volts = float("nan")
1834        if not -10 <= volts <= 10:  # Also refuses NaN
1835            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.")
1836        bit_max = int(self._dac_bit_max)
1837        return int(round((volts + 10) / 20 * bit_max))
1838
1839    def _bits_to_volts(self, value):
1840        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1841        bit_max = int(self._dac_bit_max)
1842        raw_volts = (float(value) / bit_max * 20) - 10
1843
1844        # Calculate the voltage of 1 bit
1845        lsb_volts = 20.0 / bit_max
1846
1847        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1848        clean_volts = round(raw_volts, 3)
1849
1850        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1851        if abs(raw_volts - clean_volts) <= lsb_volts:
1852            return clean_volts
1853
1854        # Otherwise, return the standard 4-decimal reading
1855        return round(raw_volts, 4)
1856
1857    def _seconds_to_cycles(self, value, name="time"):
1858        """Convert seconds to the corresponding refresh-cycle count.
1859
1860        Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do:
1861        125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is
1862        negative, not a number, or too long for the device.
1863        """
1864        try:
1865            seconds = float(value)
1866        except (TypeError, ValueError):
1867            seconds = float("nan")
1868        if not (math.isfinite(seconds) and seconds >= 0):
1869            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.")
1870        cycles = int(round(seconds * float(self.info.cycle_frequency)))
1871        if cycles > 2**32 - 1:
1872            raise PulsePalError(
1873                f"{name} is too long for the device ({2**32 - 1} cycles at most). Received {value!r}."
1874            )
1875        return cycles
1876
1877    def _cycles_to_seconds(self, value):
1878        """Convert hardware timer cycle counts to seconds."""
1879        return float(value) / float(self.info.cycle_frequency)
1880
1881    def _require_firmware(self, minimum_version, context):
1882        """Raise unless the device firmware is new enough for an op."""
1883        if (
1884            self.info.firmware_version is None
1885            or self.info.firmware_version < minimum_version
1886        ):
1887            raise PulsePalError(
1888                f"{context} requires firmware v{minimum_version} or newer. "
1889                f"Detected firmware is v{self.info.firmware_version}."
1890            )
1891
1892    def _sync_all_params(self):
1893        """Send all parameters using the packed sync op (firmware v22+).
1894
1895        Values are grouped by width so that the whole program travels as
1896        one uint32 block, one uint16 block and one uint8 block.
1897        """
1898        time_values = []
1899        for attr_name in (
1900            "phase1_duration",
1901            "inter_phase_interval",
1902            "phase2_duration",
1903            "inter_pulse_interval",
1904            "burst_duration",
1905            "inter_burst_interval",
1906            "pulse_train_duration",
1907            "pulse_train_delay",
1908        ):
1909            time_values.extend(
1910                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1911                for channel in range(1, 5)
1912            )
1913
1914        voltage_values = []
1915        for attr_name in (
1916            "phase1_voltage",
1917            "phase2_voltage",
1918            "resting_voltage",
1919        ):
1920            voltage_values.extend(
1921                self._volts_to_bits(getattr(self, attr_name)[channel])
1922                for channel in range(1, 5)
1923            )
1924
1925        single_byte_values = []
1926        for attr_name in (
1927            "is_biphasic",
1928            "custom_train_id",
1929            "custom_train_target",
1930            "custom_train_loop",
1931            "playback_mode",
1932        ):
1933            single_byte_values.extend(
1934                int(getattr(self, attr_name)[channel])
1935                for channel in range(1, 5)
1936            )
1937        single_byte_values.extend(
1938            int(self.link_trigger_channel1[channel])
1939            for channel in range(1, 5)
1940        )
1941        single_byte_values.extend(
1942            int(self.link_trigger_channel2[channel])
1943            for channel in range(1, 5)
1944        )
1945        single_byte_values.extend(
1946            int(value) for value in self.trigger_mode[1:3]
1947        )
1948
1949        self._write_serial(
1950            (self._OP_MENU_BYTE, 92),
1951            "uint8",
1952            time_values,
1953            "uint32",
1954            voltage_values,
1955            "uint16",
1956            single_byte_values,
1957            "uint8",
1958        )
1959
1960    def _sync_all_params_legacy(self):
1961        """Send all parameters using the legacy sync op (firmware v21).
1962
1963        Equivalent to `_sync_all_params`, but lays the program out
1964        channel by channel as the older firmware expects.
1965        """
1966        program_values_16 = []
1967        program_values_32 = []
1968        program_values_8 = [0] * 16
1969
1970        for channel in range(1, 5):
1971            program_values_32.extend(
1972                [
1973                    self._seconds_to_cycles(
1974                        self.phase1_duration[channel]),
1975                    self._seconds_to_cycles(
1976                        self.inter_phase_interval[channel]),
1977                    self._seconds_to_cycles(
1978                        self.phase2_duration[channel]),
1979                    self._seconds_to_cycles(
1980                        self.inter_pulse_interval[channel]),
1981                    self._seconds_to_cycles(
1982                        self.burst_duration[channel]),
1983                    self._seconds_to_cycles(
1984                        self.inter_burst_interval[channel]),
1985                    self._seconds_to_cycles(
1986                        self.pulse_train_duration[channel]),
1987                    self._seconds_to_cycles(
1988                        self.pulse_train_delay[channel]),
1989                ]
1990            )
1991
1992        for channel in range(1, 5):
1993            program_values_16.extend(
1994                [
1995                    self._volts_to_bits(self.phase1_voltage[channel]),
1996                    self._volts_to_bits(self.phase2_voltage[channel]),
1997                    self._volts_to_bits(self.resting_voltage[channel]),
1998                ]
1999            )
2000
2001        position = 0
2002        for channel in range(1, 5):
2003            program_values_8[position] = self.is_biphasic[channel]
2004            position += 1
2005            program_values_8[position] = self.custom_train_id[channel]
2006            position += 1
2007            program_values_8[position] = self.custom_train_target[channel]
2008            position += 1
2009            program_values_8[position] = self.custom_train_loop[channel]
2010            position += 1
2011
2012        program_values_tl = [0] * 8
2013        position = 0
2014        for channel in range(1, 5):
2015            program_values_tl[position] = self.link_trigger_channel1[channel]
2016            position += 1
2017        for channel in range(1, 5):
2018            program_values_tl[position] = self.link_trigger_channel2[channel]
2019            position += 1
2020
2021        self._write_serial(
2022            (self._OP_MENU_BYTE, 73),
2023            "uint8",
2024            program_values_32,
2025            "uint32",
2026            program_values_16,
2027            "uint16",
2028            program_values_8,
2029            "uint8",
2030            program_values_tl,
2031            "uint8",
2032            self.trigger_mode[1:3],
2033            "uint8",
2034        )
2035
2036    def __enter__(self):
2037        """Enter a `with` block, returning the connected device."""
2038        return self
2039
2040    def __exit__(self, exc_type, exc_value, traceback):
2041        """Disconnect and close the port when leaving a `with` block.
2042
2043        Returns:
2044            `False`, so any exception raised in the block propagates.
2045        """
2046        try:
2047            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
2048        except Exception:
2049            pass
2050        self.close()
2051        return False
2052
2053    def __del__(self):
2054        """Disconnect and close the port when the object is collected."""
2055        try:
2056            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
2057        except Exception:
2058            pass
2059
2060        try:
2061            self.close()
2062        except Exception:
2063            # Destructors should not raise; the serial object may already be
2064            # gone during interpreter shutdown.
2065            pass
class PulsePalDevice:
 181class PulsePalDevice:
 182    """A class to control a Pulse Pal device on a USB serial port.
 183
 184    Creating an instance opens the serial port, exchanges a handshake
 185    with the device, verifies that its firmware is supported, reads the
 186    device properties into `PulsePalDevice.info`, and programs the
 187    device with the default parameters.
 188
 189    ```python
 190    from PulsePal import PulsePalDevice
 191
 192    P = PulsePalDevice("COM3")
 193    P.set_output_param("phase1_voltage", 1, 5)
 194    P.trigger(1)
 195    P.close()
 196    ```
 197
 198    Here, replace "COM3" with Pulse Pal's USB serial port name.
 199    To view a list of available ports, use
 200    PulsePalDevice.serialportlist()
 201    Pulse Pal's port may not be visible if it is connected to another
 202    instance of PulsePalDevice or an external application. Use
 203    PulsePalDevice.serialportlist('all') to view all ports.
 204    If you see multiple available ports, disconnect Pulse Pal's USB plug
 205    and re-run serialportlist(). Notice which port disappears from the list.
 206
 207    PulsePalDevice is also a context manager, which closes the connection on
 208    exit even if an error is raised:
 209
 210    ```python
 211    with PulsePalDevice("COM3") as P:
 212        P.trigger(1)
 213    ```
 214
 215    The attributes below named after Pulse Pal parameters are the local
 216    copy of the device's program. Each is a five element list indexed by
 217    channel number, with index 0 unused. This way channels are addressed
 218    by the index on the device, e.g. trigger channels 1-2 and output
 219    channels 1-4. Assigning parameters does not update the device until
 220    `PulsePalDevice.sync_to_device` is called;
 221    `PulsePalDevice.set_output_param` programs one parameter right away.
 222    """
 223
 224    port: "serial.Serial"
 225    """The open `serial.Serial` port connected to the device."""
 226
 227    info: DeviceInfo
 228    """Properties of the connected device. See `DeviceInfo`."""
 229
 230    is_biphasic: list
 231    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 232
 233    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 234    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 235    phase 2.
 236    """
 237
 238    phase1_voltage: list
 239    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 240
 241    phase2_voltage: list
 242    """Voltage of the second phase of each pulse, in volts [-10, 10].
 243
 244    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 245    """
 246
 247    resting_voltage: list
 248    """Voltage held between pulses, in volts [-10, 10].
 249
 250    A new resting voltage reaches an idle channel's output at once. A
 251    channel playing a pulse train keeps playing it, and moves to the new
 252    resting voltage at its next transition to rest.
 253    """
 254
 255    phase1_duration: list
 256    """Duration of the first phase of each pulse, in seconds.
 257
 258    At least `DeviceInfo.min_pulse_width_us`.
 259    """
 260
 261    inter_phase_interval: list
 262    """Interval between the two phases of a biphasic pulse, in seconds.
 263
 264    The channel rests at `PulsePalDevice.resting_voltage` during the
 265    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 266    """
 267
 268    phase2_duration: list
 269    """Duration of the second phase of each pulse, in seconds.
 270
 271    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 272    At least `DeviceInfo.min_pulse_width_us`.
 273    """
 274
 275    inter_pulse_interval: list
 276    """Interval from the end of one pulse to the onset of the next, in
 277    seconds. At least `DeviceInfo.min_pulse_width_us`."""
 278
 279    burst_duration: list
 280    """Duration of each burst of pulses, in seconds.
 281
 282    Set to `0` to disable bursts, so that pulses continue for the whole
 283    pulse train. A pulse starts only if it ends before the burst does:
 284    its first phase, or for a biphasic pulse the whole pulse, so that
 285    the end of a burst never cuts off a second phase.
 286    """
 287
 288    inter_burst_interval: list
 289    """Interval between bursts of pulses, in seconds.
 290
 291    The channel rests at `PulsePalDevice.resting_voltage` between
 292    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 293    """
 294
 295    pulse_train_duration: list
 296    """Total duration of the pulse train, in seconds.
 297
 298    At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts
 299    short a monophasic pulse still playing. A biphasic pulse starts only
 300    if it can end by the end of the train, so that it keeps its second
 301    phase.
 302    """
 303
 304    pulse_train_delay: list
 305    """Delay from the trigger to the onset of the pulse train, in
 306    seconds."""
 307
 308    link_trigger_channel1: list
 309    """Whether each output channel is linked to trigger channel 1.
 310
 311    `1` links the output channel to trigger channel 1, `0` unlinks it.
 312    """
 313
 314    link_trigger_channel2: list
 315    """Whether each output channel is linked to trigger channel 2.
 316
 317    `1` links the output channel to trigger channel 2, `0` unlinks it.
 318    """
 319
 320    custom_train_id: list
 321    """Custom pulse train played by each output channel.
 322
 323    `0` plays the parametrically defined train. `1` or higher plays the
 324    matching custom train, previously loaded with
 325    `PulsePalDevice.send_custom_pulse_train` or
 326    `PulsePalDevice.send_custom_waveform`.
 327    """
 328
 329    custom_train_target: list
 330    """What the timestamps of a custom train mark.
 331
 332    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 333    is the onset of a burst of pulses.
 334    """
 335
 336    custom_train_loop: list
 337    """Whether a custom train repeats.
 338
 339    `1` loops the custom train until
 340    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 341    once.
 342    """
 343
 344    playback_mode: list
 345    """Continuous playback mode of parametric pulse trains after being triggered
 346
 347    - `0` plays the pulse train once until pulse_train_duration seconds
 348    - `1` plays the pulse train indefinitely, ignoring pulse_train_duration
 349    
 350    """
 351
 352    trigger_mode: list
 353    """Response of each trigger channel to an incoming TTL pulse.
 354
 355    Three element list indexed by trigger channel, with index 0 unused.
 356    Elements 1 and 2 control the respective channels on the device.
 357    Their values can be:
 358
 359    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 360      during the train are ignored.
 361    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 362    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 363    - `3` (param sync, Pulse Pal 3 only): a TTL rising edge starts and
 364      stops nothing. It loads the parameter set most recently sent by
 365      `PulsePalDevice.sync_to_device`. This is how the next trial's
 366      parameters are sent during the current trial and applied the
 367      instant it starts.
 368
 369    An output channel that is idle at a param sync edge takes its new
 370    parameters in the 50 us timer cycle the edge is detected. One that is
 371    playing a pulse train finishes that train on the parameters it
 372    started with, and takes the new ones the moment it ends, so a train
 373    that runs past the end of a trial keeps one shape throughout and the
 374    next trigger plays a whole train with the new parameters. A channel
 375    in continuous playback mode has no train end, so it keeps its
 376    parameters until something stops it.
 377
 378    While either trigger channel is in param sync mode, **only
 379    `PulsePalDevice.sync_to_device` is held back**.
 380    `PulsePalDevice.set_output_param`, `PulsePalDevice.set_trigger_param`
 381    and every other parameter method still program the device
 382    immediately. So leaving param sync mode means calling
 383    `PulsePalDevice.set_trigger_param`; a trigger mode sent by
 384    `PulsePalDevice.sync_to_device` does not take effect until a sync
 385    edge arrives.
 386
 387    A param sync channel's links to output channels are ignored. To
 388    start a pulse train on the same edge, wire the TTL to the other
 389    trigger channel as well: both edges arrive in the same timer cycle,
 390    and the parameters are loaded first.
 391
 392    Connecting a new `PulsePalDevice` takes both trigger channels out of
 393    param sync mode, so that the default parameters it programs reach the
 394    device instead of waiting for a TTL.
 395
 396    ```python
 397    P.set_trigger_param("trigger_mode", 2, 3)  # channel 2 does param sync
 398    P.phase1_voltage[1:5] = [5] * 4
 399    P.sync_to_device()                         # stored, not yet applied
 400    # ... the next rising edge on trigger channel 2 applies it ...
 401    ```
 402    """
 403
 404    _CURRENT_FIRMWARE_VERSION = 22
 405
 406    _OP_MENU_BYTE = 213
 407    _HANDSHAKE_OPCODE = 72
 408    _HANDSHAKE_RESPONSE = 75
 409    _WAVE_PAL_HANDSHAKE_RESPONSE = 87  # 'W': the device runs Wave Pal firmware
 410    _DAC_BITMAX = 65535
 411    _PARAM_MESSAGE_BYTES = 178  # Length of the parameter set sent by op 93
 412    _OLDEST_FIRMWARE_SUPPORTED = 21
 413
 414    # Parameter names in order of their parameter codes (code = index + 1),
 415    # matching _OUTPUT_PARAMETER_ATTRS and the firmware.
 416    _OUTPUT_PARAMETER_NAMES = (
 417        "is_biphasic",
 418        "phase1_voltage",
 419        "phase2_voltage",
 420        "phase1_duration",
 421        "inter_phase_interval",
 422        "phase2_duration",
 423        "inter_pulse_interval",
 424        "burst_duration",
 425        "inter_burst_interval",
 426        "pulse_train_duration",
 427        "pulse_train_delay",
 428        "link_trigger_channel1",
 429        "link_trigger_channel2",
 430        "custom_train_id",
 431        "custom_train_target",
 432        "custom_train_loop",
 433        "resting_voltage",
 434        "playback_mode",
 435    )
 436    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 437    # Output parameter codes whose values are 0 or 1
 438    _BINARY_PARAMETER_CODES = (1, 12, 13, 15, 16, 18)
 439    # Phase 1 and 2 durations, inter-pulse interval and train duration last
 440    # at least this many timer cycles, as in the MATLAB and C++ classes and
 441    # the joystick menu. A trigger channel reads its input once per cycle, so a
 442    # one cycle pulse from another Pulse Pal could fall between two reads.
 443    _MIN_PULSE_CYCLES = 2
 444    _MIN_PULSE_PARAMETER_CODES = (4, 6, 7, 10)
 445
 446    _OUTPUT_PARAMETER_ATTRS = {
 447        1: "is_biphasic",
 448        2: "phase1_voltage",
 449        3: "phase2_voltage",
 450        4: "phase1_duration",
 451        5: "inter_phase_interval",
 452        6: "phase2_duration",
 453        7: "inter_pulse_interval",
 454        8: "burst_duration",
 455        9: "inter_burst_interval",
 456        10: "pulse_train_duration",
 457        11: "pulse_train_delay",
 458        12: "link_trigger_channel1",
 459        13: "link_trigger_channel2",
 460        14: "custom_train_id",
 461        15: "custom_train_target",
 462        16: "custom_train_loop",
 463        17: "resting_voltage",
 464        18: "playback_mode",
 465    }
 466    _ENDIANNESS = "<"
 467    _STRUCT_FORMATS = {
 468        "uint8": "B",
 469        "int8": "b",
 470        "char": "c",
 471        "uint16": "H",
 472        "int16": "h",
 473        "uint32": "I",
 474        "int32": "i",
 475        "single": "f",
 476        "double": "d",
 477    }
 478    _TYPE_RANGES = {
 479        "uint8": (0, 2**8 - 1),
 480        "int8": (-(2**7), 2**7 - 1),
 481        "uint16": (0, 2**16 - 1),
 482        "int16": (-(2**15), 2**15 - 1),
 483        "uint32": (0, 2**32 - 1),
 484        "int32": (-(2**31), 2**31 - 1),
 485    }
 486
 487    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 488        """Open a connection to a Pulse Pal device.
 489
 490        Opens the serial port, exchanges the handshake, verifies the
 491        firmware version, reads the device properties into
 492        `PulsePalDevice.info`, and programs the device with the default
 493        parameters.
 494
 495        Args:
 496            port_name: USB serial port for the Pulse Pal device, such as
 497                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 498            baud_rate: Serial baud rate.
 499            timeout: Serial read timeout, in seconds.
 500
 501        Raises:
 502            PulsePalError: If the device does not return the expected
 503                handshake, or its firmware is older than v21, or its
 504                firmware is newer than this module supports. The port is
 505                closed again before any error is raised.
 506            serial.SerialException: If the serial port cannot be opened.
 507        """
 508        self.info = DeviceInfo()
 509        self._gui = None
 510        self.port = serial.Serial(
 511            port_name,
 512            baud_rate,
 513            timeout=timeout,
 514            rtscts=True,
 515        )
 516        self._closed = False
 517        try:
 518            self._start_session(port_name)
 519        except BaseException:
 520            # Otherwise the port stays open until the object is garbage
 521            # collected, and a second attempt, or WavePal.WavePalDevice,
 522            # cannot open it. The device is in an unknown state, so it is not
 523            # sent the disconnect op.
 524            self.close(send_disconnect=False)
 525            raise
 526
 527    def _start_session(self, port_name):
 528        """Handshake, check the firmware, and program the defaults."""
 529        self._dac_bit_max = self._to_decimal(0)
 530        self.info.firmware_version = None
 531        self.info.hardware_version = None
 532        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 533        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 534
 535        self._write_serial(
 536            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 537            "uint8",
 538        )
 539        handshake = self._read_serial(1, "uint8")
 540        if handshake == self._WAVE_PAL_HANDSHAKE_RESPONSE:
 541            wave_pal_version = self._read_serial(1, "uint32")
 542            raise PulsePalError(
 543                f"Error: the device on {port_name} runs Wave Pal firmware "
 544                f"(v{wave_pal_version}), not Pulse Pal firmware. To use it as "
 545                "a Pulse Pal, load Pulse Pal firmware onto it (see "
 546                "/Firmware/Readme.txt). To use it as a Wave Pal, connect "
 547                "with WavePal.WavePalDevice."
 548            )
 549        if handshake != self._HANDSHAKE_RESPONSE:
 550            raise PulsePalError(
 551                "Error: incorrect handshake returned. Expected "
 552                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 553            )
 554
 555        firmware_version = self._read_serial(1, "uint32")
 556        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 557            raise PulsePalError(
 558                "Error: Old firmware detected, v"
 559                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 560                "newer is required."
 561            )
 562        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 563            raise PulsePalError(
 564                "Error: Future firmware detected, v"
 565                f"{firmware_version}. Please update PulsePal.py or downgrade "
 566                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 567            )
 568        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 569            print(
 570                "Old firmware detected, v"
 571                f"{firmware_version}. This firmware is supported. Update to v"
 572                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 573            )
 574        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 575        self.info.firmware_version = firmware_version
 576
 577        if self.info.firmware_version > 21:
 578            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 579            self.info.hardware_version = self._read_serial(1, "uint8")
 580            self.info.cycle_period_us = self._read_serial(1, "uint32")
 581            self.info.cycle_frequency = 1 / (
 582                self.info.cycle_period_us / 1000000
 583            )
 584            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 585            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 586        else:
 587            self.info.hardware_version = 2
 588            self.info.cycle_period_us = 50
 589            self.info.cycle_frequency = 20000
 590            self.info.n_custom_pulse_trains = 2
 591            self.info.max_custom_pulses = 5000
 592        self.info.min_pulse_width_us = (
 593            self._MIN_PULSE_CYCLES * self.info.cycle_period_us
 594        )
 595
 596        # Client name op + "PYTHON" in ASCII.
 597        self._write_serial(
 598            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 599            "uint8",
 600        )
 601
 602        self.set_default_params()
 603        if self.info.hardware_version > 2:
 604            # A device left in param sync mode by an earlier session would
 605            # store the sync below instead of running it, leaving the device
 606            # on its old program until a TTL arrived. set_trigger_param is
 607            # not deferred that way, so it takes both trigger channels out of
 608            # param sync mode first. See PulsePalDevice.trigger_mode.
 609            for channel in (1, 2):
 610                self.set_trigger_param("trigger_mode", channel, 0)
 611        self.sync_to_device()
 612
 613    @staticmethod
 614    def serialportlist(ports_to_list="available"):
 615        """Return the names of the USB serial ports on this computer.
 616
 617        Called on the class, without connecting to a device, to find the
 618        port name to pass to `PulsePalDevice`:
 619
 620        ```python
 621        from PulsePal import PulsePalDevice
 622
 623        ports = PulsePalDevice.serialportlist()
 624        P = PulsePalDevice(ports[0])
 625        ```
 626
 627        Args:
 628            ports_to_list: `available` to list only the ports that are
 629                not already in use, or `all` to list every USB serial
 630                port. Not case sensitive.
 631
 632        Returns:
 633            Sorted list of port names, such as `["COM3", "COM7"]` on
 634            Windows or `["/dev/ttyACM0"]` on Linux.
 635
 636        Raises:
 637            PulsePalError: If `ports_to_list` is not `available` or
 638                `all`.
 639        """
 640        if not isinstance(ports_to_list, str):
 641            raise PulsePalError(
 642                "serialportlist() takes 'available' or 'all'."
 643            )
 644        mode = ports_to_list.lower()
 645        if mode not in ("available", "all"):
 646            raise PulsePalError(
 647                f"Unknown port list type: {ports_to_list}. "
 648                "Use 'available' or 'all'."
 649            )
 650
 651        port_names = []
 652        for port_info in serial.tools.list_ports.comports():
 653            is_usb = port_info.vid is not None or "USB" in (
 654                port_info.hwid or ""
 655            ).upper()
 656            if not is_usb:
 657                continue
 658            if mode == "available" and not PulsePalDevice._port_is_free(
 659                port_info.device
 660            ):
 661                continue
 662            port_names.append(port_info.device)
 663        return sorted(port_names)
 664
 665    @staticmethod
 666    def _port_is_free(port_name):
 667        """Return True if the port is not already open in another program."""
 668        port = serial.Serial()
 669        port.port = port_name
 670        # Leaving the control lines low avoids resetting boards that
 671        # reset on DTR while the port is probed.
 672        port.dtr = False
 673        port.rts = False
 674        try:
 675            port.open()
 676        except (serial.SerialException, OSError):
 677            return False
 678        port.close()
 679        return True
 680
 681    def set_default_params(self):
 682        """Reset the local copy of all parameters to their defaults.
 683
 684        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 685        1 second, on all four output channels, linked to trigger channel
 686        1 in normal trigger mode.
 687
 688        This updates only the local copy. Call
 689        `PulsePalDevice.sync_to_device` to program the device with them.
 690        """
 691        nan = float("nan")
 692        self.is_biphasic = [nan, 0, 0, 0, 0]
 693        self.phase1_voltage = [nan, 5, 5, 5, 5]
 694        self.phase2_voltage = [nan, -5, -5, -5, -5]
 695        self.resting_voltage = [nan, 0, 0, 0, 0]
 696        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 697        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 698        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 699        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 700        self.burst_duration = [nan, 0, 0, 0, 0]
 701        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 702        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 703        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 704        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 705        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 706        self.custom_train_id = [nan, 0, 0, 0, 0]
 707        self.custom_train_target = [nan, 0, 0, 0, 0]
 708        self.custom_train_loop = [nan, 0, 0, 0, 0]
 709        self.playback_mode = [nan, 0, 0, 0, 0]
 710        self.trigger_mode = [nan, 0, 0]
 711
 712    def set_voltage(self, channel, voltage):
 713        """Set an output channel to a fixed voltage.
 714
 715        The channel holds the voltage until it is set again or until a
 716        pulse train is triggered on it.
 717
 718        Args:
 719            channel: Output channel number, 1-4.
 720            voltage: Voltage to set, in volts [-10, 10].
 721
 722        Raises:
 723            PulsePalError: If the voltage is outside [-10, 10], or the
 724                device does not acknowledge the command.
 725        """
 726        voltage_bits = self._volts_to_bits(voltage, "voltage")
 727        self._write_serial(
 728            (self._OP_MENU_BYTE, 79, channel),
 729            "uint8",
 730            voltage_bits,
 731            "uint16",
 732        )
 733        self._read_ack("set_fixed_voltage()")
 734
 735    def set_calibration(self, channel, voltage_offset):
 736        """Calibrate the zero code of an output channel.
 737
 738        The offset is added to every voltage the channel produces, to
 739        correct for DAC offset error. It is stored in the device's
 740        EEPROM and reloaded on boot, so it only needs to be set once.
 741
 742        Requires Pulse Pal hardware v3 or newer.
 743
 744        Args:
 745            channel: Output channel number, 1-4.
 746            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 747
 748        Raises:
 749            PulsePalError: If the connected hardware is older than v3,
 750                or the device does not acknowledge the command.
 751            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 752                outside [-0.1, 0.1].
 753        """
 754        if self.info.hardware_version < 3:
 755            raise PulsePalError(
 756                "set_calibration() requires hardware v3 or newer."
 757            )
 758        if channel not in (1, 2, 3, 4):
 759            raise ValueError("channel must be 1, 2, 3 or 4")
 760        if not -0.1 <= voltage_offset <= 0.1:  # Also refuses NaN
 761            raise ValueError(
 762                "voltage_offset for zero code calibration must be in range "
 763                "[-0.1, 0.1]"
 764            )
 765        # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated)
 766        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
 767        self._write_serial(
 768            (self._OP_MENU_BYTE, 96, channel - 1),
 769            "uint8",
 770            voltage_bits,
 771            "int16",
 772        )
 773        self._read_ack("set_calibration()")
 774
 775    def set_output_param(self, param_name, channel, value):
 776        """Program an output channel parameter on the device.
 777
 778        The local copy of the parameter is updated to match, so a later
 779        `PulsePalDevice.sync_to_device` will not undo the change.
 780
 781        ```python
 782        P.set_output_param("is_biphasic", 1, 1)
 783        P.set_output_param("phase1_voltage", 1, 10)
 784        P.set_output_param(3, 1, -10)   # same, by param code
 785        P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3])
 786        P.set_output_param("phase1_duration", [2, 4], 0.002)
 787        ```
 788
 789        Setting all four channels in one call programs them with a single
 790        command (firmware v22 or newer). Otherwise, each listed channel is
 791        programmed with its own command. Channels that are not listed are
 792        not changed.
 793
 794        Args:
 795            param_name: Parameter name, as listed in
 796                `DeviceInfo.output_parameter_names`, or its integer
 797                parameter code.
 798            channel: Output channel number, 1-4, or a list of distinct
 799                output channel numbers.
 800            value: Value to set. Units are volts for voltage parameters,
 801                seconds for time parameters, and integers for enumerated
 802                parameters. See the attributes of `PulsePalDevice` for
 803                the meaning of each parameter. If `channel` is a list,
 804                either one value for all listed channels, or a list with
 805                one value per listed channel, in the same order.
 806
 807        Raises:
 808            PulsePalError: If the parameter name is not recognized, the
 809                channels or number of values are invalid, a value is out
 810                of range for the parameter (nothing is sent), or the device
 811                does not acknowledge the command. When the device refuses a
 812                value, the local copy of the parameter is first read back
 813                from the device, which resets a value it refuses.
 814        """
 815        param_code = self._get_output_param_code(param_name)
 816
 817        if isinstance(channel, numbers.Integral):
 818            channels = [int(channel)]
 819        else:
 820            channels = [int(ch) for ch in self._as_list(channel)]
 821        if (
 822            not channels
 823            or len(set(channels)) != len(channels)
 824            or any(ch not in (1, 2, 3, 4) for ch in channels)
 825        ):
 826            raise PulsePalError(
 827                "channel must be an output channel number, 1-4, or a list "
 828                "of distinct output channel numbers."
 829            )
 830        values = self._as_list(value)
 831        if len(values) == 1:
 832            values = values * len(channels)
 833        if len(values) != len(channels):
 834            raise PulsePalError(
 835                f"{len(values)} values were given for {len(channels)} "
 836                "channels. Give one value, or one value per channel."
 837            )
 838        self._check_output_values(param_code, channels, values)
 839
 840        if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21:
 841            # One op 91 command programs this parameter on all four channels
 842            values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)]
 843            data, datatype = self._encode_output_param(param_code, values_by_channel)
 844            self._write_serial(
 845                (self._OP_MENU_BYTE, 91, param_code),
 846                "uint8",
 847                data,
 848                datatype,
 849            )
 850            self._read_ack(
 851                "set_output_param()",
 852                on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)),
 853            )
 854            for ch, channel_value in zip((1, 2, 3, 4), values_by_channel):
 855                self._set_output_param_value(param_code, ch, channel_value)
 856        else:
 857            for ch, channel_value in zip(channels, values):
 858                self._set_one_output_param(param_code, ch, channel_value)
 859
 860    def set_trigger_param(self, param_name, channel, value):
 861        """Program a single trigger channel parameter on the device.
 862
 863        The local copy of the parameter is updated to match, so a later
 864        `PulsePalDevice.sync_to_device` will not undo the change.
 865
 866        ```python
 867        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 868        ```
 869
 870        Args:
 871            param_name: Parameter name, as listed in
 872                `DeviceInfo.trigger_parameter_names`, or its integer
 873                parameter code.
 874            channel: Trigger channel number, 1-2.
 875            value: Value to set. See `PulsePalDevice.trigger_mode` for
 876                the trigger modes.
 877
 878        Raises:
 879            PulsePalError: If the parameter name is not recognized, the
 880                channel or value is out of range (nothing is sent), or the
 881                device does not acknowledge the command.
 882        """
 883        original_value = value
 884        param_code = self._get_trigger_param_code(param_name)
 885        if param_code == 128:
 886            if not (isinstance(channel, numbers.Integral) and channel in (1, 2)):
 887                raise PulsePalError(
 888                    f"channel must be a trigger channel number, 1 or 2. Received {channel!r}."
 889                )
 890            self._check_trigger_mode(value, channel)
 891
 892        self._write_serial(
 893            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 894            "uint8",
 895        )
 896        self._read_ack(
 897            "program_trigger_channel_param()",
 898            on_refusal=lambda: self._refresh_trigger_mode(channel),
 899        )
 900
 901        if param_code in (1, 128):
 902            self.trigger_mode[channel] = original_value
 903
 904    def sync_to_device(self):
 905        """Program the device with the local copy of all parameters.
 906
 907        Call this after assigning to the parameter array attributes, to
 908        send every output and trigger parameter to the device in a
 909        single transaction.
 910
 911        ```python
 912        P.phase1_voltage[1:5] = [5] * 4
 913        P.sync_to_device()
 914        ```
 915
 916        On Pulse Pal 3, if either trigger channel is in param sync mode
 917        (`PulsePalDevice.trigger_mode` 3), the device stores the
 918        parameters instead of programming them, and loads them on the
 919        next rising edge of that channel. The device still acknowledges
 920        whether every value was in range. This is the only method whose
 921        effect is deferred that way.
 922
 923        Raises:
 924            PulsePalError: If a value is out of range for its parameter
 925                (nothing is sent), or the device does not acknowledge the
 926                command.
 927        """
 928        self._check_program()
 929        # _sync_all_params() for firmware v22+ uses the newer packed sync
 930        # opcode (92). _sync_all_params_legacy() uses the less efficient
 931        # legacy packed sync opcode (73).
 932        if self.info.firmware_version > 21:
 933            self._sync_all_params()
 934        else:
 935            self._sync_all_params_legacy()
 936        self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)
 937
 938    def sync_from_device(self):
 939        """Read all parameters from the device into the local copy.
 940
 941        Overwrites every parameter array attribute with the program
 942        currently stored on the device. Useful after the device has been
 943        reprogrammed from its thumb joystick.
 944
 945        Requires firmware v22 or newer.
 946
 947        Raises:
 948            PulsePalError: If the connected firmware is older than v22,
 949                or the device does not return the full parameter set.
 950        """
 951        self._require_firmware(22, "sync_from_device()")
 952        for attr_name, values in self._read_device_params().items():
 953            setattr(self, attr_name, values)
 954
 955    def _read_device_params(self):
 956        """Read the device's parameters (op 93).
 957
 958        Returns a dict of parameter lists, indexed by channel like the
 959        parameter array attributes. Continuous playback mode is not part
 960        of op 93, so `playback_mode` is not included.
 961        """
 962        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 963        # The device sends the whole parameter set as one message, so read it in one go and
 964        # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md.
 965        message = self._read_raw(self._PARAM_MESSAGE_BYTES)
 966        values = struct.unpack(
 967            f"{self._ENDIANNESS}32I12H26B",
 968            message,
 969        )
 970        params = {}
 971
 972        for index, attr_name in enumerate((
 973                "phase1_duration",
 974                "inter_phase_interval",
 975                "phase2_duration",
 976                "inter_pulse_interval",
 977                "burst_duration",
 978                "inter_burst_interval",
 979                "pulse_train_duration",
 980                "pulse_train_delay",
 981        )):
 982            cycles = values[index * 4:index * 4 + 4]
 983            params[attr_name] = [float("nan")] + [self._cycles_to_seconds(x) for x in cycles]
 984
 985        for index, attr_name in enumerate((
 986                "phase1_voltage",
 987                "phase2_voltage",
 988                "resting_voltage",
 989        )):
 990            bits = values[32 + index * 4:32 + index * 4 + 4]
 991            params[attr_name] = [float("nan")] + [self._bits_to_volts(x) for x in bits]
 992
 993        for index, attr_name in enumerate((
 994                "is_biphasic",
 995                "custom_train_id",
 996                "custom_train_target",
 997                "custom_train_loop",
 998                "link_trigger_channel1",
 999                "link_trigger_channel2",
1000        )):
1001            params[attr_name] = [float("nan")] + list(values[44 + index * 4:44 + index * 4 + 4])
1002        params["trigger_mode"] = [float("nan")] + list(values[68:70])
1003        return params
1004
1005    def send_custom_pulse_train(
1006        self,
1007        custom_train_id,
1008        pulse_times,
1009        pulse_voltages,
1010    ):
1011        """Load a custom pulse train onto the device.
1012
1013        A custom pulse train is an arbitrary list of pulse onset times
1014        and voltages, replacing the parametric pulse voltage and onset timing.
1015        Set `PulsePalDevice.custom_train_id` on an output channel to play
1016        the train there.
1017
1018        ```python
1019        P.send_custom_pulse_train(
1020            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1021        )
1022        P.set_output_param("custom_train_id", 1, 2)
1023        ```
1024
1025        Args:
1026            custom_train_id: Custom train to load, from 1 to
1027                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1028                4 on Pulse Pal 3).
1029            pulse_times: Pulse onset times, in seconds, relative to the
1030                start of the train. Accepts a list, tuple or NumPy
1031                array.
1032            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
1033                Must be the same length as `pulse_times`.
1034
1035        Raises:
1036            PulsePalError: If `custom_train_id` is out of range,
1037                `pulse_times` and `pulse_voltages` differ in length,
1038                there are more pulses than
1039                `DeviceInfo.max_custom_pulses`, a pulse time is less
1040                than `DeviceInfo.min_pulse_width_us` after the one before
1041                it (after rounding to the device's timer cycle), or the
1042                device does not acknowledge the command.
1043        """
1044        pulse_times = self._as_list(pulse_times)
1045        pulse_voltages = self._as_list(pulse_voltages)
1046        n_pulses = len(pulse_times)
1047        if n_pulses != len(pulse_voltages):
1048            raise PulsePalError(
1049                "pulse_times and pulse_voltages must be the same length."
1050            )
1051
1052        pulse_times_cycles = [
1053            self._seconds_to_cycles(pulse_time, "pulse_times")
1054            for pulse_time in pulse_times
1055        ]
1056        pulse_voltage_bits = [
1057            self._volts_to_bits(voltage, "pulse_voltages")
1058            for voltage in pulse_voltages
1059        ]
1060
1061        self._send_custom_train(
1062            custom_train_id,
1063            pulse_times_cycles,
1064            pulse_voltage_bits,
1065            "send_custom_pulse_train()",
1066        )
1067
1068    def send_custom_waveform(
1069        self,
1070        custom_train_id,
1071        pulse_width,
1072        pulse_voltages,
1073    ):
1074        """Load an arbitrary waveform onto the device.
1075
1076        A convenience shorthand for
1077        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1078        confluent pulses, so that `pulse_voltages` is played as a
1079        waveform sampled every `pulse_width` seconds.
1080
1081        Set the channel's `PulsePalDevice.phase1_duration` to
1082        `pulse_width` as well, so that each sample is held for the
1083        sampling period.
1084
1085        ```python
1086        import math
1087
1088        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1089        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1090        P.set_output_param("custom_train_id", 2, 1)
1091        P.set_output_param("phase1_duration", 2, 0.001)
1092        ```
1093
1094        Args:
1095            custom_train_id: Custom train to load, from 1 to
1096                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1097                4 on Pulse Pal 3).
1098            pulse_width: Sampling period, in seconds. Each voltage is
1099                held for this long.
1100            pulse_voltages: Waveform samples, in volts [-10, 10].
1101                Accepts a list, tuple or NumPy array.
1102
1103        Raises:
1104            PulsePalError: If `custom_train_id` is out of range, there
1105                are more samples than `DeviceInfo.max_custom_pulses`,
1106                `pulse_width` rounds to less than
1107                `DeviceInfo.min_pulse_width_us`, or the device does not
1108                acknowledge the command.
1109        """
1110        pulse_voltages = self._as_list(pulse_voltages)
1111        n_pulses = len(pulse_voltages)
1112        pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width")
1113        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
1114        pulse_voltage_bits = [
1115            self._volts_to_bits(voltage, "pulse_voltages")
1116            for voltage in pulse_voltages
1117        ]
1118
1119        self._send_custom_train(
1120            custom_train_id,
1121            pulse_times,
1122            pulse_voltage_bits,
1123            "send_custom_waveform()",
1124        )
1125
1126    def trigger(
1127        self,
1128        channel1=None,
1129        channel2=None,
1130        channel3=None,
1131        channel4=None,
1132    ):
1133        """Trigger output channels in software.
1134
1135        Triggered channels start their pulse trains together. Three
1136        calling conventions are accepted:
1137
1138        ```python
1139        P.trigger(1, 0, 1, 0)   # one flag per channel
1140        P.trigger(3)            # a single channel number
1141        P.trigger([1, 4])       # several channel numbers
1142        ```
1143
1144        Channel numbers may be NumPy integers, and a list of them may be a
1145        NumPy array. A channel that is already playing a pulse train
1146        ignores the trigger, and `stop()` cancels a trigger that has not
1147        started its channel yet.
1148
1149        Args:
1150            channel1: `1` to trigger channel 1, otherwise `0`; or, when
1151                it is the only argument given, a single channel number
1152                or a list of channel numbers to trigger.
1153            channel2: `1` to trigger channel 2, otherwise `0`.
1154            channel3: `1` to trigger channel 3, otherwise `0`.
1155            channel4: `1` to trigger channel 4, otherwise `0`.
1156
1157        Raises:
1158            PulsePalError: If a channel number is not 1-4, or a flag is not
1159                0 or 1. Nothing is triggered.
1160        """
1161        trigger_byte = 0
1162
1163        # Options 2 & 3: Only one argument was provided
1164        if channel2 is None and channel3 is None and channel4 is None:
1165            # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.)
1166            for ch in self._output_channel_numbers(channel1, "trigger()"):
1167                trigger_byte |= (1 << (ch - 1))
1168
1169        # Option 1: Original input scheme (logicals for each channel)
1170        else:
1171            # Fallback to 0 if an argument was omitted via kwargs
1172            flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)]
1173            for bit, flag in enumerate(flags):
1174                if not (isinstance(flag, numbers.Integral) and flag in (0, 1)):
1175                    raise PulsePalError(
1176                        "trigger(): with more than one argument, each is a flag for one "
1177                        f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}."
1178                    )
1179                trigger_byte |= int(flag) << bit
1180
1181        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
1182
1183    def sd_settings(self, settings_file_name, op):
1184        """Save, load, or delete a settings file on the microSD card.
1185
1186        A settings file holds a complete Pulse Pal program, so that it
1187        can be recalled later from software or from the device's front
1188        panel. Loading a file also refreshes the local copy of the
1189        parameters, via `PulsePalDevice.sync_from_device`.
1190
1191        ```python
1192        P.sd_settings("MyProtocol.pps", "save")
1193        ```
1194
1195        Args:
1196            settings_file_name: Settings file name, at most 15 ASCII
1197                characters including the required `.pps` extension.
1198            op: `"save"`, `"load"`, or `"delete"`.
1199
1200        Raises:
1201            PulsePalError: If the file name has no `.pps` extension or
1202                is too long, `op` is not one of the three operations, or
1203                the device does not acknowledge the command. A load that
1204                fails leaves the device on its own default parameters
1205                (not the ones `set_default_params` sets), and the local
1206                copy is read back from the device before the error is
1207                raised.
1208        """
1209        if ".pps" not in settings_file_name:
1210            raise PulsePalError(
1211                "Error: The file name must have a valid .pps extension."
1212            )
1213        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1214        try:
1215            op_byte = op_byte_by_name[str(op).lower()]
1216        except KeyError as exc:
1217            raise PulsePalError(
1218                "File op must be: 'save', 'load' or 'delete'."
1219            ) from exc
1220
1221        filename_bytes = settings_file_name.encode("ascii")
1222        if len(filename_bytes) > 15:
1223            raise PulsePalError("settings_file_name is too long.")
1224        self._write_serial(
1225            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1226            "uint8",
1227            list(filename_bytes),
1228            "uint8",
1229        )
1230        if self.info.firmware_version > 21:
1231            # Sent after the file operation has finished. A refused load leaves the device on
1232            # its default parameters, so the local copy is read back first.
1233            self._read_ack(
1234                "sd_settings()",
1235                on_refusal=self.sync_from_device if op_byte == 2 else None,
1236            )
1237        elif op_byte == 2:
1238            time.sleep(0.1)  # Firmware v21 does not acknowledge, so allow time for the load
1239        if op_byte == 2:
1240            self.sync_from_device()
1241
1242    def stop(self, channels=None):
1243        """Stop pulse trains currently playing on the device.
1244
1245        Every output channel returns to its
1246        `PulsePalDevice.resting_voltage`.
1247
1248        Args:
1249            channels (list or tuple, optional): A list of channels to stop, e.g.,
1250                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single
1251                channel number. NumPy integers and arrays are accepted. Default is
1252                None, which stops all channels. (Requires firmware v22+)
1253
1254        Raises:
1255            PulsePalError: If a channel number is not 1-4.
1256        """
1257        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1258
1259        if channels is not None:
1260            if self.info.firmware_version < 22:
1261                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1262
1263            bit_code = 0
1264            for ch in self._output_channel_numbers(channels, "stop()"):
1265                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1266                bit_code |= 1 << (ch - 1)
1267
1268        # Send
1269        if self.info.firmware_version < 22:
1270            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1271        else:
1272            self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")
1273
1274    def format_microsd(self, timeout=30):
1275        """Format the device's microSD card.
1276
1277        Erases every settings file stored on the device and resets its
1278        parameters to the defaults. The user is prompted at the console
1279        to confirm before anything is erased.
1280
1281        Requires Pulse Pal hardware v3 or newer.
1282
1283        Args:
1284            timeout: Seconds to wait for the device to report that
1285                formatting has finished.
1286
1287        Returns:
1288            `None` once the card has been formatted, or an empty string
1289            if the user declines the confirmation prompt.
1290
1291        Raises:
1292            PulsePalError: If the connected hardware is older than v3, if
1293                the device reports that formatting failed, or if it does
1294                not report a result within `timeout` seconds.
1295        """
1296        if self.info.hardware_version < 3:
1297            raise PulsePalError(
1298                "format_microsd() requires hardware v3 or newer."
1299            )
1300
1301        print("*** Pulse Pal microSD Formatter ***")
1302        print("This will format Pulse Pal's microSD card,")
1303        print("erase all settings files on the device")
1304        print("and reset all parameters to defaults.")
1305
1306        reply = input("Do you want to continue (y/n)")
1307
1308        if reply.strip().lower() != "y":
1309            print("Choice confirmed - microSD Card NOT formatted.")
1310            return ""
1311
1312        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1313
1314        # The device replies with lines of status text, the last of which
1315        # contains "!", and then a confirm byte (1 if the card was formatted,
1316        # 0 if not). The confirm byte is sent after the device has reloaded
1317        # its default parameters, so it can arrive well after the text. It
1318        # must be read here: left in the buffer, it would be taken as the
1319        # reply to the next command, and every reply after that would be
1320        # read one byte late.
1321        start = time.time()
1322        message = bytearray()
1323        flag_index = -1
1324        line_end = -1
1325
1326        while time.time() - start < timeout:
1327            n_waiting = self.bytes_available()
1328            if n_waiting:
1329                message.extend(self.port.read(n_waiting))
1330                flag_index = message.find(b"!")
1331                if flag_index >= 0:
1332                    line_end = message.find(b"\n", flag_index)
1333                if line_end >= 0 and len(message) > line_end + 1:
1334                    break
1335            time.sleep(0.01)
1336        if line_end < 0 or len(message) <= line_end + 1:
1337            raise PulsePalError(
1338                "Error: Pulse Pal did not report the result of formatting "
1339                f"its microSD card within {timeout} s."
1340            )
1341        confirm = message[line_end + 1]
1342
1343        text = bytes(message[:flag_index]).decode(
1344            "ascii", errors="replace"
1345        ).rstrip()
1346        if text:
1347            print(text)
1348
1349        self.set_default_params()
1350        if confirm != 1:
1351            raise PulsePalError(
1352                "Error: Pulse Pal could not format its microSD card."
1353            )
1354        return None
1355
1356    def gui(self, block=None, theme=None):
1357        """Open the Pulse Pal parameter GUI, or focus an open one.
1358
1359        The GUI edits its own copy of the parameters, and loads them to
1360        the device when its 'Load to Device' button is clicked. The
1361        window closes automatically when the device is closed or
1362        deleted.
1363
1364        Calling this while the GUI is already open focuses the existing
1365        window rather than opening a second one.
1366
1367        Args:
1368            block: If `True`, the call returns when the GUI is closed. If
1369                `False`, the call returns immediately, and the host
1370                application must run the Tk event loop. If `None`, the
1371                GUI blocks only when the host does not already provide a
1372                Tk event loop, e.g. when launched from a script.
1373            theme: `"light"` or `"dark"` to select the color theme, or
1374                `None` to match the desktop theme. Passing a theme to an
1375                already-open GUI recolors it in place.
1376
1377        Returns:
1378            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1379
1380        Raises:
1381            ValueError: If the theme name is not recognized.
1382        """
1383        gui = getattr(self, "_gui", None)
1384        if gui is not None and not gui.is_closed:
1385            if theme is not None:
1386                gui.set_theme(theme)
1387            gui.focus()
1388            return gui
1389
1390        try:
1391            from .PulsePalGUI import PulsePalGUI
1392        except ImportError:
1393            from PulsePalGUI import PulsePalGUI
1394
1395        gui = PulsePalGUI(self, theme=theme)
1396        self._gui = gui
1397        gui.start(block=block)
1398        return gui
1399
1400    def close(self, send_disconnect=True):
1401        """Close the connection to the device, and the GUI if open.
1402
1403        Safe to call more than once; later calls do nothing. Called
1404        automatically when leaving a `with` block and when the object is
1405        garbage collected.
1406
1407        Args:
1408            send_disconnect: If `True`, tell the device that the client
1409                is disconnecting before closing the port. Set to `False`
1410                when the device is in an unknown state, such as after a
1411                failed handshake.
1412        """
1413        gui = getattr(self, "_gui", None)
1414        self._gui = None
1415        if gui is not None:
1416            try:
1417                gui.close()
1418            except Exception:
1419                # Cleanup must not raise; Tk may already be torn down
1420                pass
1421
1422        if getattr(self, "_closed", True):
1423            return
1424
1425        try:
1426            if send_disconnect and self.port and self.port.is_open:
1427                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1428        finally:
1429            if self.port and self.port.is_open:
1430                self.port.close()
1431            self._closed = True
1432
1433    def bytes_available(self):
1434        """Return the number of bytes waiting in the serial read buffer.
1435
1436        Returns:
1437            Count of bytes that can be read without blocking.
1438        """
1439        return self.port.in_waiting
1440
1441    def _send_custom_train(self, custom_train_id, pulse_times_cycles,
1442                           pulse_voltage_bits, context):
1443        """Send a custom pulse train, already converted to device units.
1444
1445        Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76
1446        (trains 1 and 2 only) on firmware v21.
1447        """
1448        n_trains = self.info.n_custom_pulse_trains
1449        try:
1450            train_id = int(custom_train_id)
1451        except (TypeError, ValueError):
1452            train_id = None
1453        if (
1454            train_id is None
1455            or train_id != custom_train_id
1456            or not 1 <= train_id <= n_trains
1457        ):
1458            raise PulsePalError(
1459                f"{context}: custom_train_id must be an integer from 1 to "
1460                f"{n_trains}. Received {custom_train_id}."
1461            )
1462        n_pulses = len(pulse_times_cycles)
1463        if n_pulses > self.info.max_custom_pulses:
1464            raise PulsePalError(
1465                f"{context}: {n_pulses} pulses were given. Pulse Pal can "
1466                f"store up to {self.info.max_custom_pulses} pulses per "
1467                "custom train."
1468            )
1469        # The device plays each pulse until the next one's time, so a time
1470        # that is not later than the one before it would freeze the output
1471        # for the rest of the train, and one only a cycle later would play
1472        # a pulse too short for a trigger channel to detect (see
1473        # _MIN_PULSE_CYCLES). The check is on the times the device will
1474        # receive, after rounding to its timer cycles.
1475        for i in range(1, n_pulses):
1476            if (pulse_times_cycles[i] - pulse_times_cycles[i - 1]
1477                    < self._MIN_PULSE_CYCLES):
1478                raise PulsePalError(
1479                    f"{context}: pulse times must increase, by at least "
1480                    f"{self._min_pulse_seconds()} s ({self._MIN_PULSE_CYCLES} "
1481                    f"cycles of the device's {self.info.cycle_period_us} us "
1482                    f"timer). Pulse {i + 1} is at "
1483                    f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, "
1484                    f"and pulse {i} is at "
1485                    f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s."
1486                )
1487
1488        if self.info.firmware_version > 21:
1489            header = (self._OP_MENU_BYTE, 95, train_id - 1)
1490        else:
1491            header = (self._OP_MENU_BYTE, 74 + train_id)
1492        self._write_serial(
1493            header,
1494            "uint8",
1495            n_pulses,
1496            "uint32",
1497            pulse_times_cycles,
1498            "uint32",
1499            pulse_voltage_bits,
1500            "uint16",
1501        )
1502        self._read_ack(context)
1503
1504    def _get_output_param_code(self, param_name):
1505        """Resolve an output parameter name or code to its code."""
1506        if isinstance(param_name, str):
1507            try:
1508                return self.info.output_parameter_names.index(param_name) + 1
1509            except ValueError as exc:
1510                raise PulsePalError(
1511                    f"Unknown output parameter: {param_name}."
1512                ) from exc
1513        return int(param_name)
1514
1515    def _get_trigger_param_code(self, param_name):
1516        """Resolve a trigger parameter name or code to its code."""
1517        if isinstance(param_name, str):
1518            try:
1519                index = self.info.trigger_parameter_names.index(param_name)
1520                return index + 128
1521            except ValueError as exc:
1522                raise PulsePalError(
1523                    f"Unknown trigger parameter: {param_name}."
1524                ) from exc
1525        return int(param_name)
1526
1527    def _write_serial(self, *args):
1528        """Write one or more data/type pairs to the serial port."""
1529        if len(args) % 2 != 0:
1530            raise PulsePalError(
1531                "Serial writes require data/type argument pairs."
1532            )
1533
1534        payload = bytearray()
1535        for i in range(0, len(args), 2):
1536            payload.extend(self._pack_values(args[i], args[i + 1]))
1537
1538        bytes_written = self.port.write(bytes(payload))
1539        if bytes_written != len(payload):
1540            raise PulsePalError(
1541                f"Error: wrote {bytes_written} byte(s), expected to write "
1542                f"{len(payload)} byte(s)."
1543            )
1544
1545    def _read_raw(self, n_bytes):
1546        """Read exactly n_bytes from the serial port."""
1547        message_bytes = self.port.read(n_bytes)
1548        if len(message_bytes) < n_bytes:
1549            raise PulsePalError(
1550                f"Error: serial port timed out. "
1551                f"{len(message_bytes)} byte(s) read. "
1552                f"Expected {n_bytes} byte(s)."
1553            )
1554        return message_bytes
1555
1556    def _read_serial(self, n_values, datatype):
1557        """Read values from the serial port and unpack them with struct."""
1558        datatype = self._normalize_datatype(datatype)
1559        fmt = self._STRUCT_FORMATS[datatype]
1560        n_values = int(n_values)
1561        n_bytes = n_values * struct.calcsize(fmt)
1562        message_bytes = self._read_raw(n_bytes)
1563
1564        values = struct.unpack(
1565            f"{self._ENDIANNESS}{n_values}{fmt}",
1566            message_bytes,
1567        )
1568        if n_values == 1:
1569            return values[0]
1570        return list(values)
1571
1572    def _read_ack(self, context, on_refusal=None):
1573        """Read a one-byte acknowledgement from the device.
1574
1575        The device replies 1 if it executed the command, or 0 if it rejected
1576        the command because a value was out of range. On a 0, on_refusal()
1577        is called before the error is raised: the device resets a value it
1578        refuses, so callers use it to read the value back into the local copy.
1579        """
1580        try:
1581            acknowledgement = self._read_serial(1, "uint8")
1582        except PulsePalError as exc:
1583            raise PulsePalError(
1584                "Error: Pulse Pal did not return an acknowledgement byte "
1585                f"after a call to {context}."
1586            ) from exc
1587        if acknowledgement != 1:
1588            refusal = PulsePalError(
1589                f"Error: Pulse Pal rejected the command sent by {context}. "
1590                "This usually means that a channel number, parameter code or "
1591                "value was out of range for the connected device."
1592            )
1593            if on_refusal is not None:
1594                try:
1595                    on_refusal()
1596                except PulsePalError as exc:
1597                    raise refusal from exc
1598            raise refusal
1599
1600    def _pack_values(self, values, datatype):
1601        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1602        datatype = self._normalize_datatype(datatype)
1603        fmt = self._STRUCT_FORMATS[datatype]
1604        values_list = self._as_list(values)
1605
1606        if datatype == "char":
1607            values_list = self._normalize_char_values(values_list)
1608        elif datatype in self._TYPE_RANGES:
1609            values_list = self._normalize_int_values(values_list, datatype)
1610        else:
1611            values_list = [float(value) for value in values_list]
1612
1613        return struct.pack(
1614            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1615            *values_list,
1616        )
1617
1618    def _normalize_datatype(self, datatype):
1619        """Return the datatype name, rejecting unsupported types."""
1620        datatype = str(datatype)
1621        if datatype not in self._STRUCT_FORMATS:
1622            raise PulsePalError(
1623                f"Error: {datatype} is not a data type supported by "
1624                "PulsePalObject."
1625            )
1626        return datatype
1627
1628    def _normalize_char_values(self, values):
1629        """Coerce str, int and bytes values to single ASCII bytes."""
1630        normalized = []
1631        for value in values:
1632            if isinstance(value, str):
1633                value = value.encode("ascii")
1634            if isinstance(value, int):
1635                value = bytes((value,))
1636            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1637                raise PulsePalError(
1638                    "char values must be one-byte bytes, chars, or "
1639                    "integers."
1640                )
1641            normalized.append(bytes(value))
1642        return normalized
1643
1644    def _normalize_int_values(self, values, datatype):
1645        """Coerce values to ints, rejecting any out of range."""
1646        min_value, max_value = self._TYPE_RANGES[datatype]
1647        normalized = []
1648        for value in values:
1649            value = int(value)
1650            if not min_value <= value <= max_value:
1651                raise PulsePalError(
1652                    f"Value {value} is out of range for {datatype} "
1653                    f"({min_value} to {max_value})."
1654                )
1655            normalized.append(value)
1656        return normalized
1657
1658    def _as_list(self, values):
1659        """Return values as a flat list, wrapping scalars in one."""
1660        if isinstance(values, np.ndarray):
1661            return values.ravel().tolist()
1662        if isinstance(values, (bytes, bytearray, str)):
1663            return [values]
1664        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1665            return [values]
1666        try:
1667            return list(values)
1668        except TypeError:
1669            return [values]
1670
1671    def _set_one_output_param(self, param_code, channel, value):
1672        """Program one output parameter on one channel (op 74)."""
1673        data, datatype = self._encode_output_param(param_code, value)
1674        self._write_serial(
1675            (self._OP_MENU_BYTE, 74, param_code, channel),
1676            "uint8",
1677            data,
1678            datatype,
1679        )
1680        self._read_ack(
1681            "program_output_channel_param()",
1682            on_refusal=lambda: self._refresh_output_param(param_code, (channel,)),
1683        )
1684        self._set_output_param_value(param_code, channel, value)
1685
1686    def _encode_output_param(self, param_code, values):
1687        """Convert output parameter values to device units.
1688
1689        Returns the converted values and the datatype the device reads for
1690        this parameter code: DAC bits for voltages, hardware timer cycles
1691        for times, and bytes for everything else.
1692        """
1693        values = self._as_list(values)
1694        name = self._OUTPUT_PARAMETER_ATTRS.get(param_code, f"parameter {param_code}")
1695        if param_code in (2, 3, 17):
1696            return [self._volts_to_bits(v, name) for v in values], "uint16"
1697        if 4 <= param_code <= 11:
1698            return [self._seconds_to_cycles(v, name) for v in values], "uint32"
1699        return values, "uint8"
1700
1701    def _check_output_values(self, param_code, channels, values):
1702        """Raise PulsePalError, before anything is sent, for values the device cannot play.
1703
1704        The device refuses most of them too, but it also resets them (see validateOutputParams()
1705        in /Firmware/PulsePal3/USBOps.ino), so the local copy would describe a different program.
1706        """
1707        for ch, value in zip(channels, values):
1708            self._check_output_value(param_code, value, ch)
1709
1710    def _check_program(self):
1711        """Check the whole local copy before sync_to_device() sends it. See _check_output_values()."""
1712        for param_code, attr_name in self._OUTPUT_PARAMETER_ATTRS.items():
1713            values = getattr(self, attr_name)
1714            for ch in range(1, 5):
1715                self._check_output_value(param_code, values[ch], ch)
1716        for ch in (1, 2):
1717            self._check_trigger_mode(self.trigger_mode[ch], ch)
1718
1719    def _check_output_value(self, param_code, value, channel):
1720        """Raise PulsePalError if a value can never be played for this output parameter."""
1721        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1722        if attr_name is None:
1723            return  # The device refuses a code it does not know
1724        name = f"{attr_name} on channel {channel}"
1725        if param_code in (2, 3, 17):
1726            self._volts_to_bits(value, name)
1727        elif 4 <= param_code <= 11:
1728            cycles = self._seconds_to_cycles(value, name)
1729            # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001,
1730            # but is exactly 2 cycles of 50 us
1731            if cycles < self._MIN_PULSE_CYCLES and param_code in self._MIN_PULSE_PARAMETER_CODES:
1732                raise PulsePalError(
1733                    f"{name} must be at least {self._min_pulse_seconds()} s "
1734                    f"({self._MIN_PULSE_CYCLES} cycles of the device's {self.info.cycle_period_us} us "
1735                    f"timer), so that trigger channels can detect the pulses. Received {value!r}."
1736                )
1737        elif param_code == 14:
1738            self._check_whole_number(value, name, 0, self.info.n_custom_pulse_trains)
1739        elif param_code in self._BINARY_PARAMETER_CODES:
1740            self._check_whole_number(value, name, 0, 1)
1741
1742    def _min_pulse_seconds(self):
1743        """The shortest pulse phase, interval or train, in seconds. See _MIN_PULSE_CYCLES."""
1744        return self._cycles_to_seconds(self._MIN_PULSE_CYCLES)
1745
1746    def _check_trigger_mode(self, value, channel):
1747        """Raise PulsePalError if value is not a trigger mode the device has."""
1748        # Param sync mode (3) is on Pulse Pal 3 with firmware v22 or newer
1749        param_sync = (self.info.hardware_version or 2) > 2 and self.info.firmware_version > 21
1750        self._check_whole_number(
1751            value, f"trigger_mode on trigger channel {channel}", 0, 3 if param_sync else 2
1752        )
1753
1754    def _check_whole_number(self, value, name, low, high):
1755        """Raise PulsePalError unless value is a whole number from low to high."""
1756        valid = (
1757            isinstance(value, numbers.Real)
1758            and math.isfinite(value)
1759            and value == int(value)
1760            and low <= value <= high
1761        )
1762        if not valid:
1763            raise PulsePalError(
1764                f"{name} must be a whole number from {low} to {high}. Received {value!r}."
1765            )
1766
1767    def _refresh_output_param(self, param_code, channels):
1768        """Read an output parameter back from the device into the local copy, for these channels.
1769
1770        Called after the device refused a value for them, which it resets. The parameter's other
1771        channels, and other parameters, keep their local values, which may hold edits not yet synced.
1772        """
1773        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1774        if attr_name is None or self.info.firmware_version < 22:
1775            return  # Nothing local to correct, or no op 93 to read it with
1776        device_values = self._read_device_params().get(attr_name)
1777        if device_values is None:
1778            return  # Not in op 93 (continuous playback mode)
1779        local_values = getattr(self, attr_name)
1780        for ch in channels:
1781            local_values[ch] = device_values[ch]
1782
1783    def _refresh_trigger_mode(self, channel):
1784        """Read a trigger channel's mode back from the device, after the device refused a new one."""
1785        if self.info.firmware_version > 21 and channel in (1, 2):
1786            self.trigger_mode[channel] = self._read_device_params()["trigger_mode"][channel]
1787
1788    def _refresh_after_refused_sync(self):
1789        """Read the parameters back after the device refused a sync_to_device() set.
1790
1791        The device programs the set with the refused values reset, so the local copy is read back.
1792        In param sync mode the device stores the set instead, and op 93 would return the parameters
1793        playing now, so the local copy is left as it is.
1794        """
1795        if self.info.firmware_version > 21 and 3 not in self.trigger_mode[1:3]:
1796            self.sync_from_device()
1797
1798    def _output_channel_numbers(self, channels, context):
1799        """Return output channel numbers as a list of ints.
1800
1801        channels is one integer (NumPy integers included) or an iterable of them (NumPy arrays
1802        included). Raises PulsePalError for anything else, or a channel that is not 1-4.
1803        """
1804        channel_list = self._as_list(channels)
1805        if not all(isinstance(ch, numbers.Integral) and 1 <= ch <= 4 for ch in channel_list):
1806            raise PulsePalError(
1807                f"{context}: channels must be output channel numbers 1-4, as one integer or a "
1808                f"list of them. Received {channels!r}."
1809            )
1810        return [int(ch) for ch in channel_list]
1811
1812    def _set_output_param_value(self, param_code, channel, original_value):
1813        """Store a programmed value in its local parameter array."""
1814        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1815        if attr_name is None:
1816            return
1817        values = getattr(self, attr_name)
1818        values[channel] = original_value
1819
1820    def _to_decimal(self, value):
1821        """Convert a value to a Decimal with PulsePal precision."""
1822        return Decimal(value).quantize(Decimal("1.0000"))
1823
1824    def _volts_to_bits(self, value, name="voltage"):
1825        """Convert -10 V to +10 V to the corresponding DAC bit value.
1826
1827        Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do.
1828        Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would
1829        play a different voltage.
1830        """
1831        try:
1832            volts = float(value)
1833        except (TypeError, ValueError):
1834            volts = float("nan")
1835        if not -10 <= volts <= 10:  # Also refuses NaN
1836            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.")
1837        bit_max = int(self._dac_bit_max)
1838        return int(round((volts + 10) / 20 * bit_max))
1839
1840    def _bits_to_volts(self, value):
1841        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1842        bit_max = int(self._dac_bit_max)
1843        raw_volts = (float(value) / bit_max * 20) - 10
1844
1845        # Calculate the voltage of 1 bit
1846        lsb_volts = 20.0 / bit_max
1847
1848        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1849        clean_volts = round(raw_volts, 3)
1850
1851        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1852        if abs(raw_volts - clean_volts) <= lsb_volts:
1853            return clean_volts
1854
1855        # Otherwise, return the standard 4-decimal reading
1856        return round(raw_volts, 4)
1857
1858    def _seconds_to_cycles(self, value, name="time"):
1859        """Convert seconds to the corresponding refresh-cycle count.
1860
1861        Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do:
1862        125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is
1863        negative, not a number, or too long for the device.
1864        """
1865        try:
1866            seconds = float(value)
1867        except (TypeError, ValueError):
1868            seconds = float("nan")
1869        if not (math.isfinite(seconds) and seconds >= 0):
1870            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.")
1871        cycles = int(round(seconds * float(self.info.cycle_frequency)))
1872        if cycles > 2**32 - 1:
1873            raise PulsePalError(
1874                f"{name} is too long for the device ({2**32 - 1} cycles at most). Received {value!r}."
1875            )
1876        return cycles
1877
1878    def _cycles_to_seconds(self, value):
1879        """Convert hardware timer cycle counts to seconds."""
1880        return float(value) / float(self.info.cycle_frequency)
1881
1882    def _require_firmware(self, minimum_version, context):
1883        """Raise unless the device firmware is new enough for an op."""
1884        if (
1885            self.info.firmware_version is None
1886            or self.info.firmware_version < minimum_version
1887        ):
1888            raise PulsePalError(
1889                f"{context} requires firmware v{minimum_version} or newer. "
1890                f"Detected firmware is v{self.info.firmware_version}."
1891            )
1892
1893    def _sync_all_params(self):
1894        """Send all parameters using the packed sync op (firmware v22+).
1895
1896        Values are grouped by width so that the whole program travels as
1897        one uint32 block, one uint16 block and one uint8 block.
1898        """
1899        time_values = []
1900        for attr_name in (
1901            "phase1_duration",
1902            "inter_phase_interval",
1903            "phase2_duration",
1904            "inter_pulse_interval",
1905            "burst_duration",
1906            "inter_burst_interval",
1907            "pulse_train_duration",
1908            "pulse_train_delay",
1909        ):
1910            time_values.extend(
1911                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1912                for channel in range(1, 5)
1913            )
1914
1915        voltage_values = []
1916        for attr_name in (
1917            "phase1_voltage",
1918            "phase2_voltage",
1919            "resting_voltage",
1920        ):
1921            voltage_values.extend(
1922                self._volts_to_bits(getattr(self, attr_name)[channel])
1923                for channel in range(1, 5)
1924            )
1925
1926        single_byte_values = []
1927        for attr_name in (
1928            "is_biphasic",
1929            "custom_train_id",
1930            "custom_train_target",
1931            "custom_train_loop",
1932            "playback_mode",
1933        ):
1934            single_byte_values.extend(
1935                int(getattr(self, attr_name)[channel])
1936                for channel in range(1, 5)
1937            )
1938        single_byte_values.extend(
1939            int(self.link_trigger_channel1[channel])
1940            for channel in range(1, 5)
1941        )
1942        single_byte_values.extend(
1943            int(self.link_trigger_channel2[channel])
1944            for channel in range(1, 5)
1945        )
1946        single_byte_values.extend(
1947            int(value) for value in self.trigger_mode[1:3]
1948        )
1949
1950        self._write_serial(
1951            (self._OP_MENU_BYTE, 92),
1952            "uint8",
1953            time_values,
1954            "uint32",
1955            voltage_values,
1956            "uint16",
1957            single_byte_values,
1958            "uint8",
1959        )
1960
1961    def _sync_all_params_legacy(self):
1962        """Send all parameters using the legacy sync op (firmware v21).
1963
1964        Equivalent to `_sync_all_params`, but lays the program out
1965        channel by channel as the older firmware expects.
1966        """
1967        program_values_16 = []
1968        program_values_32 = []
1969        program_values_8 = [0] * 16
1970
1971        for channel in range(1, 5):
1972            program_values_32.extend(
1973                [
1974                    self._seconds_to_cycles(
1975                        self.phase1_duration[channel]),
1976                    self._seconds_to_cycles(
1977                        self.inter_phase_interval[channel]),
1978                    self._seconds_to_cycles(
1979                        self.phase2_duration[channel]),
1980                    self._seconds_to_cycles(
1981                        self.inter_pulse_interval[channel]),
1982                    self._seconds_to_cycles(
1983                        self.burst_duration[channel]),
1984                    self._seconds_to_cycles(
1985                        self.inter_burst_interval[channel]),
1986                    self._seconds_to_cycles(
1987                        self.pulse_train_duration[channel]),
1988                    self._seconds_to_cycles(
1989                        self.pulse_train_delay[channel]),
1990                ]
1991            )
1992
1993        for channel in range(1, 5):
1994            program_values_16.extend(
1995                [
1996                    self._volts_to_bits(self.phase1_voltage[channel]),
1997                    self._volts_to_bits(self.phase2_voltage[channel]),
1998                    self._volts_to_bits(self.resting_voltage[channel]),
1999                ]
2000            )
2001
2002        position = 0
2003        for channel in range(1, 5):
2004            program_values_8[position] = self.is_biphasic[channel]
2005            position += 1
2006            program_values_8[position] = self.custom_train_id[channel]
2007            position += 1
2008            program_values_8[position] = self.custom_train_target[channel]
2009            position += 1
2010            program_values_8[position] = self.custom_train_loop[channel]
2011            position += 1
2012
2013        program_values_tl = [0] * 8
2014        position = 0
2015        for channel in range(1, 5):
2016            program_values_tl[position] = self.link_trigger_channel1[channel]
2017            position += 1
2018        for channel in range(1, 5):
2019            program_values_tl[position] = self.link_trigger_channel2[channel]
2020            position += 1
2021
2022        self._write_serial(
2023            (self._OP_MENU_BYTE, 73),
2024            "uint8",
2025            program_values_32,
2026            "uint32",
2027            program_values_16,
2028            "uint16",
2029            program_values_8,
2030            "uint8",
2031            program_values_tl,
2032            "uint8",
2033            self.trigger_mode[1:3],
2034            "uint8",
2035        )
2036
2037    def __enter__(self):
2038        """Enter a `with` block, returning the connected device."""
2039        return self
2040
2041    def __exit__(self, exc_type, exc_value, traceback):
2042        """Disconnect and close the port when leaving a `with` block.
2043
2044        Returns:
2045            `False`, so any exception raised in the block propagates.
2046        """
2047        try:
2048            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
2049        except Exception:
2050            pass
2051        self.close()
2052        return False
2053
2054    def __del__(self):
2055        """Disconnect and close the port when the object is collected."""
2056        try:
2057            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
2058        except Exception:
2059            pass
2060
2061        try:
2062            self.close()
2063        except Exception:
2064            # Destructors should not raise; the serial object may already be
2065            # gone during interpreter shutdown.
2066            pass

A class to control a Pulse Pal device on a USB serial port.

Creating an instance opens the serial port, exchanges a handshake with the device, verifies that its firmware is supported, reads the device properties into PulsePalDevice.info, and programs the device with the default parameters.

from PulsePal import PulsePalDevice

P = PulsePalDevice("COM3")
P.set_output_param("phase1_voltage", 1, 5)
P.trigger(1)
P.close()

Here, replace "COM3" with Pulse Pal's USB serial port name. To view a list of available ports, use PulsePalDevice.serialportlist() Pulse Pal's port may not be visible if it is connected to another instance of PulsePalDevice or an external application. Use PulsePalDevice.serialportlist('all') to view all ports. If you see multiple available ports, disconnect Pulse Pal's USB plug and re-run serialportlist(). Notice which port disappears from the list.

PulsePalDevice is also a context manager, which closes the connection on exit even if an error is raised:

with PulsePalDevice("COM3") as P:
    P.trigger(1)

The attributes below named after Pulse Pal parameters are the local copy of the device's program. Each is a five element list indexed by channel number, with index 0 unused. This way channels are addressed by the index on the device, e.g. trigger channels 1-2 and output channels 1-4. Assigning parameters does not update the device until PulsePalDevice.sync_to_device is called; PulsePalDevice.set_output_param programs one parameter right away.

PulsePalDevice(port_name, baud_rate=12000000, timeout=10)
487    def __init__(self, port_name, baud_rate=12000000, timeout=10):
488        """Open a connection to a Pulse Pal device.
489
490        Opens the serial port, exchanges the handshake, verifies the
491        firmware version, reads the device properties into
492        `PulsePalDevice.info`, and programs the device with the default
493        parameters.
494
495        Args:
496            port_name: USB serial port for the Pulse Pal device, such as
497                `COM3` on Windows or `/dev/ttyACM0` on Linux.
498            baud_rate: Serial baud rate.
499            timeout: Serial read timeout, in seconds.
500
501        Raises:
502            PulsePalError: If the device does not return the expected
503                handshake, or its firmware is older than v21, or its
504                firmware is newer than this module supports. The port is
505                closed again before any error is raised.
506            serial.SerialException: If the serial port cannot be opened.
507        """
508        self.info = DeviceInfo()
509        self._gui = None
510        self.port = serial.Serial(
511            port_name,
512            baud_rate,
513            timeout=timeout,
514            rtscts=True,
515        )
516        self._closed = False
517        try:
518            self._start_session(port_name)
519        except BaseException:
520            # Otherwise the port stays open until the object is garbage
521            # collected, and a second attempt, or WavePal.WavePalDevice,
522            # cannot open it. The device is in an unknown state, so it is not
523            # sent the disconnect op.
524            self.close(send_disconnect=False)
525            raise

Open a connection to a Pulse Pal device.

Opens the serial port, exchanges the handshake, verifies the firmware version, reads the device properties into PulsePalDevice.info, and programs the device with the default parameters.

Arguments:
  • port_name: USB serial port for the Pulse Pal device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • baud_rate: Serial baud rate.
  • timeout: Serial read timeout, in seconds.
Raises:
  • PulsePalError: If the device does not return the expected handshake, or its firmware is older than v21, or its firmware is newer than this module supports. The port is closed again before any error is raised.
  • 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.

is_biphasic: list

Pulse shape per channel: 0 for monophasic, 1 for biphasic.

Monophasic pulses use only the phase 1 parameters. Biphasic pulses follow phase 1 with PulsePalDevice.inter_phase_interval and then phase 2.

phase1_voltage: list

Voltage of the first phase of each pulse, in volts [-10, 10].

phase2_voltage: list

Voltage of the second phase of each pulse, in volts [-10, 10].

Used only when PulsePalDevice.is_biphasic is 1 for the channel.

resting_voltage: list

Voltage held between pulses, in volts [-10, 10].

A new resting voltage reaches an idle channel's output at once. A channel playing a pulse train keeps playing it, and moves to the new resting voltage at its next transition to rest.

phase1_duration: list

Duration of the first phase of each pulse, in seconds.

At least DeviceInfo.min_pulse_width_us.

inter_phase_interval: list

Interval between the two phases of a biphasic pulse, in seconds.

The channel rests at PulsePalDevice.resting_voltage during the interval. Used only when PulsePalDevice.is_biphasic is 1.

phase2_duration: list

Duration of the second phase of each pulse, in seconds.

Used only when PulsePalDevice.is_biphasic is 1 for the channel. At least DeviceInfo.min_pulse_width_us.

inter_pulse_interval: list

Interval from the end of one pulse to the onset of the next, in seconds. At least DeviceInfo.min_pulse_width_us.

burst_duration: list

Duration of each burst of pulses, in seconds.

Set to 0 to disable bursts, so that pulses continue for the whole pulse train. A pulse starts only if it ends before the burst does: its first phase, or for a biphasic pulse the whole pulse, so that the end of a burst never cuts off a second phase.

inter_burst_interval: list

Interval between bursts of pulses, in seconds.

The channel rests at PulsePalDevice.resting_voltage between bursts. Ignored when PulsePalDevice.burst_duration is 0.

pulse_train_duration: list

Total duration of the pulse train, in seconds.

At least DeviceInfo.min_pulse_width_us. The end of the train cuts short a monophasic pulse still playing. A biphasic pulse starts only if it can end by the end of the train, so that it keeps its second phase.

pulse_train_delay: list

Delay from the trigger to the onset of the pulse train, in seconds.

custom_train_id: list

Custom pulse train played by each output channel.

0 plays the parametrically defined train. 1 or higher plays the matching custom train, previously loaded with PulsePalDevice.send_custom_pulse_train or PulsePalDevice.send_custom_waveform.

custom_train_target: list

What the timestamps of a custom train mark.

0 if each timestamp is the onset of a pulse, 1 if each timestamp is the onset of a burst of pulses.

custom_train_loop: list

Whether a custom train repeats.

1 loops the custom train until PulsePalDevice.pulse_train_duration has elapsed, 0 plays it once.

playback_mode: list

Continuous playback mode of parametric pulse trains after being triggered

  • 0 plays the pulse train once until pulse_train_duration seconds
  • 1 plays the pulse train indefinitely, ignoring pulse_train_duration
trigger_mode: list

Response of each trigger channel to an incoming TTL pulse.

Three element list indexed by trigger channel, with index 0 unused. Elements 1 and 2 control the respective channels on the device. Their values can be:

  • 0 (normal): a TTL rising edge starts the pulse train, and edges during the train are ignored.
  • 1 (toggle): same as 0 but a TTL rising edge during the train stops it.
  • 2 (pulse gated): the train runs only while the trigger TTL is high.
  • 3 (param sync, Pulse Pal 3 only): a TTL rising edge starts and stops nothing. It loads the parameter set most recently sent by PulsePalDevice.sync_to_device. This is how the next trial's parameters are sent during the current trial and applied the instant it starts.

An output channel that is idle at a param sync edge takes its new parameters in the 50 us timer cycle the edge is detected. One that is playing a pulse train finishes that train on the parameters it started with, and takes the new ones the moment it ends, so a train that runs past the end of a trial keeps one shape throughout and the next trigger plays a whole train with the new parameters. A channel in continuous playback mode has no train end, so it keeps its parameters until something stops it.

While either trigger channel is in param sync mode, only PulsePalDevice.sync_to_device is held back. PulsePalDevice.set_output_param, PulsePalDevice.set_trigger_param and every other parameter method still program the device immediately. So leaving param sync mode means calling PulsePalDevice.set_trigger_param; a trigger mode sent by PulsePalDevice.sync_to_device does not take effect until a sync edge arrives.

A param sync channel's links to output channels are ignored. To start a pulse train on the same edge, wire the TTL to the other trigger channel as well: both edges arrive in the same timer cycle, and the parameters are loaded first.

Connecting a new PulsePalDevice takes both trigger channels out of param sync mode, so that the default parameters it programs reach the device instead of waiting for a TTL.

P.set_trigger_param("trigger_mode", 2, 3)  # channel 2 does param sync
P.phase1_voltage[1:5] = [5] * 4
P.sync_to_device()                         # stored, not yet applied
# ... the next rising edge on trigger channel 2 applies it ...
@staticmethod
def serialportlist(ports_to_list='available'):
613    @staticmethod
614    def serialportlist(ports_to_list="available"):
615        """Return the names of the USB serial ports on this computer.
616
617        Called on the class, without connecting to a device, to find the
618        port name to pass to `PulsePalDevice`:
619
620        ```python
621        from PulsePal import PulsePalDevice
622
623        ports = PulsePalDevice.serialportlist()
624        P = PulsePalDevice(ports[0])
625        ```
626
627        Args:
628            ports_to_list: `available` to list only the ports that are
629                not already in use, or `all` to list every USB serial
630                port. Not case sensitive.
631
632        Returns:
633            Sorted list of port names, such as `["COM3", "COM7"]` on
634            Windows or `["/dev/ttyACM0"]` on Linux.
635
636        Raises:
637            PulsePalError: If `ports_to_list` is not `available` or
638                `all`.
639        """
640        if not isinstance(ports_to_list, str):
641            raise PulsePalError(
642                "serialportlist() takes 'available' or 'all'."
643            )
644        mode = ports_to_list.lower()
645        if mode not in ("available", "all"):
646            raise PulsePalError(
647                f"Unknown port list type: {ports_to_list}. "
648                "Use 'available' or 'all'."
649            )
650
651        port_names = []
652        for port_info in serial.tools.list_ports.comports():
653            is_usb = port_info.vid is not None or "USB" in (
654                port_info.hwid or ""
655            ).upper()
656            if not is_usb:
657                continue
658            if mode == "available" and not PulsePalDevice._port_is_free(
659                port_info.device
660            ):
661                continue
662            port_names.append(port_info.device)
663        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 PulsePalDevice:

from PulsePal import PulsePalDevice

ports = PulsePalDevice.serialportlist()
P = PulsePalDevice(ports[0])
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"] on Windows or ["/dev/ttyACM0"] on Linux.

Raises:
  • PulsePalError: If ports_to_list is not available or all.
def set_default_params(self):
681    def set_default_params(self):
682        """Reset the local copy of all parameters to their defaults.
683
684        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
685        1 second, on all four output channels, linked to trigger channel
686        1 in normal trigger mode.
687
688        This updates only the local copy. Call
689        `PulsePalDevice.sync_to_device` to program the device with them.
690        """
691        nan = float("nan")
692        self.is_biphasic = [nan, 0, 0, 0, 0]
693        self.phase1_voltage = [nan, 5, 5, 5, 5]
694        self.phase2_voltage = [nan, -5, -5, -5, -5]
695        self.resting_voltage = [nan, 0, 0, 0, 0]
696        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
697        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
698        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
699        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
700        self.burst_duration = [nan, 0, 0, 0, 0]
701        self.inter_burst_interval = [nan, 0, 0, 0, 0]
702        self.pulse_train_duration = [nan, 1, 1, 1, 1]
703        self.pulse_train_delay = [nan, 0, 0, 0, 0]
704        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
705        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
706        self.custom_train_id = [nan, 0, 0, 0, 0]
707        self.custom_train_target = [nan, 0, 0, 0, 0]
708        self.custom_train_loop = [nan, 0, 0, 0, 0]
709        self.playback_mode = [nan, 0, 0, 0, 0]
710        self.trigger_mode = [nan, 0, 0]

Reset the local copy of all parameters to their defaults.

The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for 1 second, on all four output channels, linked to trigger channel 1 in normal trigger mode.

This updates only the local copy. Call PulsePalDevice.sync_to_device to program the device with them.

def set_voltage(self, channel, voltage):
712    def set_voltage(self, channel, voltage):
713        """Set an output channel to a fixed voltage.
714
715        The channel holds the voltage until it is set again or until a
716        pulse train is triggered on it.
717
718        Args:
719            channel: Output channel number, 1-4.
720            voltage: Voltage to set, in volts [-10, 10].
721
722        Raises:
723            PulsePalError: If the voltage is outside [-10, 10], or the
724                device does not acknowledge the command.
725        """
726        voltage_bits = self._volts_to_bits(voltage, "voltage")
727        self._write_serial(
728            (self._OP_MENU_BYTE, 79, channel),
729            "uint8",
730            voltage_bits,
731            "uint16",
732        )
733        self._read_ack("set_fixed_voltage()")

Set an output channel to a fixed voltage.

The channel holds the voltage until it is set again or until a pulse train is triggered on it.

Arguments:
  • channel: Output channel number, 1-4.
  • voltage: Voltage to set, in volts [-10, 10].
Raises:
  • PulsePalError: If the voltage is outside [-10, 10], or the device does not acknowledge the command.
def set_calibration(self, channel, voltage_offset):
735    def set_calibration(self, channel, voltage_offset):
736        """Calibrate the zero code of an output channel.
737
738        The offset is added to every voltage the channel produces, to
739        correct for DAC offset error. It is stored in the device's
740        EEPROM and reloaded on boot, so it only needs to be set once.
741
742        Requires Pulse Pal hardware v3 or newer.
743
744        Args:
745            channel: Output channel number, 1-4.
746            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
747
748        Raises:
749            PulsePalError: If the connected hardware is older than v3,
750                or the device does not acknowledge the command.
751            ValueError: If `channel` is not 1-4, or `voltage_offset` is
752                outside [-0.1, 0.1].
753        """
754        if self.info.hardware_version < 3:
755            raise PulsePalError(
756                "set_calibration() requires hardware v3 or newer."
757            )
758        if channel not in (1, 2, 3, 4):
759            raise ValueError("channel must be 1, 2, 3 or 4")
760        if not -0.1 <= voltage_offset <= 0.1:  # Also refuses NaN
761            raise ValueError(
762                "voltage_offset for zero code calibration must be in range "
763                "[-0.1, 0.1]"
764            )
765        # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated)
766        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
767        self._write_serial(
768            (self._OP_MENU_BYTE, 96, channel - 1),
769            "uint8",
770            voltage_bits,
771            "int16",
772        )
773        self._read_ack("set_calibration()")

Calibrate the zero code of an output channel.

The offset is added to every voltage the channel produces, to correct for DAC offset error. It is stored in the device's EEPROM and reloaded on boot, so it only needs to be set once.

Requires Pulse Pal hardware v3 or newer.

Arguments:
  • channel: Output channel number, 1-4.
  • voltage_offset: Offset to apply, in volts [-0.1, 0.1].
Raises:
  • PulsePalError: If the connected hardware is older than v3, or the device does not acknowledge the command.
  • ValueError: If channel is not 1-4, or voltage_offset is outside [-0.1, 0.1].
def set_output_param(self, param_name, channel, value):
775    def set_output_param(self, param_name, channel, value):
776        """Program an output channel parameter on the device.
777
778        The local copy of the parameter is updated to match, so a later
779        `PulsePalDevice.sync_to_device` will not undo the change.
780
781        ```python
782        P.set_output_param("is_biphasic", 1, 1)
783        P.set_output_param("phase1_voltage", 1, 10)
784        P.set_output_param(3, 1, -10)   # same, by param code
785        P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3])
786        P.set_output_param("phase1_duration", [2, 4], 0.002)
787        ```
788
789        Setting all four channels in one call programs them with a single
790        command (firmware v22 or newer). Otherwise, each listed channel is
791        programmed with its own command. Channels that are not listed are
792        not changed.
793
794        Args:
795            param_name: Parameter name, as listed in
796                `DeviceInfo.output_parameter_names`, or its integer
797                parameter code.
798            channel: Output channel number, 1-4, or a list of distinct
799                output channel numbers.
800            value: Value to set. Units are volts for voltage parameters,
801                seconds for time parameters, and integers for enumerated
802                parameters. See the attributes of `PulsePalDevice` for
803                the meaning of each parameter. If `channel` is a list,
804                either one value for all listed channels, or a list with
805                one value per listed channel, in the same order.
806
807        Raises:
808            PulsePalError: If the parameter name is not recognized, the
809                channels or number of values are invalid, a value is out
810                of range for the parameter (nothing is sent), or the device
811                does not acknowledge the command. When the device refuses a
812                value, the local copy of the parameter is first read back
813                from the device, which resets a value it refuses.
814        """
815        param_code = self._get_output_param_code(param_name)
816
817        if isinstance(channel, numbers.Integral):
818            channels = [int(channel)]
819        else:
820            channels = [int(ch) for ch in self._as_list(channel)]
821        if (
822            not channels
823            or len(set(channels)) != len(channels)
824            or any(ch not in (1, 2, 3, 4) for ch in channels)
825        ):
826            raise PulsePalError(
827                "channel must be an output channel number, 1-4, or a list "
828                "of distinct output channel numbers."
829            )
830        values = self._as_list(value)
831        if len(values) == 1:
832            values = values * len(channels)
833        if len(values) != len(channels):
834            raise PulsePalError(
835                f"{len(values)} values were given for {len(channels)} "
836                "channels. Give one value, or one value per channel."
837            )
838        self._check_output_values(param_code, channels, values)
839
840        if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21:
841            # One op 91 command programs this parameter on all four channels
842            values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)]
843            data, datatype = self._encode_output_param(param_code, values_by_channel)
844            self._write_serial(
845                (self._OP_MENU_BYTE, 91, param_code),
846                "uint8",
847                data,
848                datatype,
849            )
850            self._read_ack(
851                "set_output_param()",
852                on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)),
853            )
854            for ch, channel_value in zip((1, 2, 3, 4), values_by_channel):
855                self._set_output_param_value(param_code, ch, channel_value)
856        else:
857            for ch, channel_value in zip(channels, values):
858                self._set_one_output_param(param_code, ch, channel_value)

Program an output channel parameter on the device.

The local copy of the parameter is updated to match, so a later PulsePalDevice.sync_to_device will not undo the change.

P.set_output_param("is_biphasic", 1, 1)
P.set_output_param("phase1_voltage", 1, 10)
P.set_output_param(3, 1, -10)   # same, by param code
P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3])
P.set_output_param("phase1_duration", [2, 4], 0.002)

Setting all four channels in one call programs them with a single command (firmware v22 or newer). Otherwise, each listed channel is programmed with its own command. Channels that are not listed are not changed.

Arguments:
  • param_name: Parameter name, as listed in DeviceInfo.output_parameter_names, or its integer parameter code.
  • channel: Output channel number, 1-4, or a list of distinct output channel numbers.
  • value: Value to set. Units are volts for voltage parameters, seconds for time parameters, and integers for enumerated parameters. See the attributes of PulsePalDevice for the meaning of each parameter. If channel is a list, either one value for all listed channels, or a list with one value per listed channel, in the same order.
Raises:
  • PulsePalError: If the parameter name is not recognized, the channels or number of values are invalid, a value is out of range for the parameter (nothing is sent), or the device does not acknowledge the command. When the device refuses a value, the local copy of the parameter is first read back from the device, which resets a value it refuses.
def set_trigger_param(self, param_name, channel, value):
860    def set_trigger_param(self, param_name, channel, value):
861        """Program a single trigger channel parameter on the device.
862
863        The local copy of the parameter is updated to match, so a later
864        `PulsePalDevice.sync_to_device` will not undo the change.
865
866        ```python
867        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
868        ```
869
870        Args:
871            param_name: Parameter name, as listed in
872                `DeviceInfo.trigger_parameter_names`, or its integer
873                parameter code.
874            channel: Trigger channel number, 1-2.
875            value: Value to set. See `PulsePalDevice.trigger_mode` for
876                the trigger modes.
877
878        Raises:
879            PulsePalError: If the parameter name is not recognized, the
880                channel or value is out of range (nothing is sent), or the
881                device does not acknowledge the command.
882        """
883        original_value = value
884        param_code = self._get_trigger_param_code(param_name)
885        if param_code == 128:
886            if not (isinstance(channel, numbers.Integral) and channel in (1, 2)):
887                raise PulsePalError(
888                    f"channel must be a trigger channel number, 1 or 2. Received {channel!r}."
889                )
890            self._check_trigger_mode(value, channel)
891
892        self._write_serial(
893            (self._OP_MENU_BYTE, 74, param_code, channel, value),
894            "uint8",
895        )
896        self._read_ack(
897            "program_trigger_channel_param()",
898            on_refusal=lambda: self._refresh_trigger_mode(channel),
899        )
900
901        if param_code in (1, 128):
902            self.trigger_mode[channel] = original_value

Program a single trigger channel parameter on the device.

The local copy of the parameter is updated to match, so a later PulsePalDevice.sync_to_device will not undo the change.

P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
Arguments:
Raises:
  • PulsePalError: If the parameter name is not recognized, the channel or value is out of range (nothing is sent), or the device does not acknowledge the command.
def sync_to_device(self):
904    def sync_to_device(self):
905        """Program the device with the local copy of all parameters.
906
907        Call this after assigning to the parameter array attributes, to
908        send every output and trigger parameter to the device in a
909        single transaction.
910
911        ```python
912        P.phase1_voltage[1:5] = [5] * 4
913        P.sync_to_device()
914        ```
915
916        On Pulse Pal 3, if either trigger channel is in param sync mode
917        (`PulsePalDevice.trigger_mode` 3), the device stores the
918        parameters instead of programming them, and loads them on the
919        next rising edge of that channel. The device still acknowledges
920        whether every value was in range. This is the only method whose
921        effect is deferred that way.
922
923        Raises:
924            PulsePalError: If a value is out of range for its parameter
925                (nothing is sent), or the device does not acknowledge the
926                command.
927        """
928        self._check_program()
929        # _sync_all_params() for firmware v22+ uses the newer packed sync
930        # opcode (92). _sync_all_params_legacy() uses the less efficient
931        # legacy packed sync opcode (73).
932        if self.info.firmware_version > 21:
933            self._sync_all_params()
934        else:
935            self._sync_all_params_legacy()
936        self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)

Program the device with the local copy of all parameters.

Call this after assigning to the parameter array attributes, to send every output and trigger parameter to the device in a single transaction.

P.phase1_voltage[1:5] = [5] * 4
P.sync_to_device()

On Pulse Pal 3, if either trigger channel is in param sync mode (PulsePalDevice.trigger_mode 3), the device stores the parameters instead of programming them, and loads them on the next rising edge of that channel. The device still acknowledges whether every value was in range. This is the only method whose effect is deferred that way.

Raises:
  • PulsePalError: If a value is out of range for its parameter (nothing is sent), or the device does not acknowledge the command.
def sync_from_device(self):
938    def sync_from_device(self):
939        """Read all parameters from the device into the local copy.
940
941        Overwrites every parameter array attribute with the program
942        currently stored on the device. Useful after the device has been
943        reprogrammed from its thumb joystick.
944
945        Requires firmware v22 or newer.
946
947        Raises:
948            PulsePalError: If the connected firmware is older than v22,
949                or the device does not return the full parameter set.
950        """
951        self._require_firmware(22, "sync_from_device()")
952        for attr_name, values in self._read_device_params().items():
953            setattr(self, attr_name, values)

Read all parameters from the device into the local copy.

Overwrites every parameter array attribute with the program currently stored on the device. Useful after the device has been reprogrammed from its thumb joystick.

Requires firmware v22 or newer.

Raises:
  • PulsePalError: If the connected firmware is older than v22, or the device does not return the full parameter set.
def send_custom_pulse_train(self, custom_train_id, pulse_times, pulse_voltages):
1005    def send_custom_pulse_train(
1006        self,
1007        custom_train_id,
1008        pulse_times,
1009        pulse_voltages,
1010    ):
1011        """Load a custom pulse train onto the device.
1012
1013        A custom pulse train is an arbitrary list of pulse onset times
1014        and voltages, replacing the parametric pulse voltage and onset timing.
1015        Set `PulsePalDevice.custom_train_id` on an output channel to play
1016        the train there.
1017
1018        ```python
1019        P.send_custom_pulse_train(
1020            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1021        )
1022        P.set_output_param("custom_train_id", 1, 2)
1023        ```
1024
1025        Args:
1026            custom_train_id: Custom train to load, from 1 to
1027                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1028                4 on Pulse Pal 3).
1029            pulse_times: Pulse onset times, in seconds, relative to the
1030                start of the train. Accepts a list, tuple or NumPy
1031                array.
1032            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
1033                Must be the same length as `pulse_times`.
1034
1035        Raises:
1036            PulsePalError: If `custom_train_id` is out of range,
1037                `pulse_times` and `pulse_voltages` differ in length,
1038                there are more pulses than
1039                `DeviceInfo.max_custom_pulses`, a pulse time is less
1040                than `DeviceInfo.min_pulse_width_us` after the one before
1041                it (after rounding to the device's timer cycle), or the
1042                device does not acknowledge the command.
1043        """
1044        pulse_times = self._as_list(pulse_times)
1045        pulse_voltages = self._as_list(pulse_voltages)
1046        n_pulses = len(pulse_times)
1047        if n_pulses != len(pulse_voltages):
1048            raise PulsePalError(
1049                "pulse_times and pulse_voltages must be the same length."
1050            )
1051
1052        pulse_times_cycles = [
1053            self._seconds_to_cycles(pulse_time, "pulse_times")
1054            for pulse_time in pulse_times
1055        ]
1056        pulse_voltage_bits = [
1057            self._volts_to_bits(voltage, "pulse_voltages")
1058            for voltage in pulse_voltages
1059        ]
1060
1061        self._send_custom_train(
1062            custom_train_id,
1063            pulse_times_cycles,
1064            pulse_voltage_bits,
1065            "send_custom_pulse_train()",
1066        )

Load a custom pulse train onto the device.

A custom pulse train is an arbitrary list of pulse onset times and voltages, replacing the parametric pulse voltage and onset timing. Set PulsePalDevice.custom_train_id on an output channel to play the train there.

P.send_custom_pulse_train(
    2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
)
P.set_output_param("custom_train_id", 1, 2)
Arguments:
  • custom_train_id: Custom train to load, from 1 to DeviceInfo.n_custom_pulse_trains (2 on Pulse Pal 2, 4 on Pulse Pal 3).
  • pulse_times: Pulse onset times, in seconds, relative to the start of the train. Accepts a list, tuple or NumPy array.
  • pulse_voltages: Voltage of each pulse, in volts [-10, 10]. Must be the same length as pulse_times.
Raises:
def send_custom_waveform(self, custom_train_id, pulse_width, pulse_voltages):
1068    def send_custom_waveform(
1069        self,
1070        custom_train_id,
1071        pulse_width,
1072        pulse_voltages,
1073    ):
1074        """Load an arbitrary waveform onto the device.
1075
1076        A convenience shorthand for
1077        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1078        confluent pulses, so that `pulse_voltages` is played as a
1079        waveform sampled every `pulse_width` seconds.
1080
1081        Set the channel's `PulsePalDevice.phase1_duration` to
1082        `pulse_width` as well, so that each sample is held for the
1083        sampling period.
1084
1085        ```python
1086        import math
1087
1088        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1089        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1090        P.set_output_param("custom_train_id", 2, 1)
1091        P.set_output_param("phase1_duration", 2, 0.001)
1092        ```
1093
1094        Args:
1095            custom_train_id: Custom train to load, from 1 to
1096                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1097                4 on Pulse Pal 3).
1098            pulse_width: Sampling period, in seconds. Each voltage is
1099                held for this long.
1100            pulse_voltages: Waveform samples, in volts [-10, 10].
1101                Accepts a list, tuple or NumPy array.
1102
1103        Raises:
1104            PulsePalError: If `custom_train_id` is out of range, there
1105                are more samples than `DeviceInfo.max_custom_pulses`,
1106                `pulse_width` rounds to less than
1107                `DeviceInfo.min_pulse_width_us`, or the device does not
1108                acknowledge the command.
1109        """
1110        pulse_voltages = self._as_list(pulse_voltages)
1111        n_pulses = len(pulse_voltages)
1112        pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width")
1113        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
1114        pulse_voltage_bits = [
1115            self._volts_to_bits(voltage, "pulse_voltages")
1116            for voltage in pulse_voltages
1117        ]
1118
1119        self._send_custom_train(
1120            custom_train_id,
1121            pulse_times,
1122            pulse_voltage_bits,
1123            "send_custom_waveform()",
1124        )

Load an arbitrary waveform onto the device.

A convenience shorthand for PulsePalDevice.send_custom_pulse_train with evenly spaced, confluent pulses, so that pulse_voltages is played as a waveform sampled every pulse_width seconds.

Set the channel's PulsePalDevice.phase1_duration to pulse_width as well, so that each sample is held for the sampling period.

import math

samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
P.set_output_param("custom_train_id", 2, 1)
P.set_output_param("phase1_duration", 2, 0.001)
Arguments:
  • custom_train_id: Custom train to load, from 1 to DeviceInfo.n_custom_pulse_trains (2 on Pulse Pal 2, 4 on Pulse Pal 3).
  • pulse_width: Sampling period, in seconds. Each voltage is held for this long.
  • pulse_voltages: Waveform samples, in volts [-10, 10]. Accepts a list, tuple or NumPy array.
Raises:
def trigger(self, channel1=None, channel2=None, channel3=None, channel4=None):
1126    def trigger(
1127        self,
1128        channel1=None,
1129        channel2=None,
1130        channel3=None,
1131        channel4=None,
1132    ):
1133        """Trigger output channels in software.
1134
1135        Triggered channels start their pulse trains together. Three
1136        calling conventions are accepted:
1137
1138        ```python
1139        P.trigger(1, 0, 1, 0)   # one flag per channel
1140        P.trigger(3)            # a single channel number
1141        P.trigger([1, 4])       # several channel numbers
1142        ```
1143
1144        Channel numbers may be NumPy integers, and a list of them may be a
1145        NumPy array. A channel that is already playing a pulse train
1146        ignores the trigger, and `stop()` cancels a trigger that has not
1147        started its channel yet.
1148
1149        Args:
1150            channel1: `1` to trigger channel 1, otherwise `0`; or, when
1151                it is the only argument given, a single channel number
1152                or a list of channel numbers to trigger.
1153            channel2: `1` to trigger channel 2, otherwise `0`.
1154            channel3: `1` to trigger channel 3, otherwise `0`.
1155            channel4: `1` to trigger channel 4, otherwise `0`.
1156
1157        Raises:
1158            PulsePalError: If a channel number is not 1-4, or a flag is not
1159                0 or 1. Nothing is triggered.
1160        """
1161        trigger_byte = 0
1162
1163        # Options 2 & 3: Only one argument was provided
1164        if channel2 is None and channel3 is None and channel4 is None:
1165            # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.)
1166            for ch in self._output_channel_numbers(channel1, "trigger()"):
1167                trigger_byte |= (1 << (ch - 1))
1168
1169        # Option 1: Original input scheme (logicals for each channel)
1170        else:
1171            # Fallback to 0 if an argument was omitted via kwargs
1172            flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)]
1173            for bit, flag in enumerate(flags):
1174                if not (isinstance(flag, numbers.Integral) and flag in (0, 1)):
1175                    raise PulsePalError(
1176                        "trigger(): with more than one argument, each is a flag for one "
1177                        f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}."
1178                    )
1179                trigger_byte |= int(flag) << bit
1180
1181        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")

Trigger output channels in software.

Triggered channels start their pulse trains together. Three calling conventions are accepted:

P.trigger(1, 0, 1, 0)   # one flag per channel
P.trigger(3)            # a single channel number
P.trigger([1, 4])       # several channel numbers

Channel numbers may be NumPy integers, and a list of them may be a NumPy array. A channel that is already playing a pulse train ignores the trigger, and stop() cancels a trigger that has not started its channel yet.

Arguments:
  • channel1: 1 to trigger channel 1, otherwise 0; or, when it is the only argument given, a single channel number or a list of channel numbers to trigger.
  • channel2: 1 to trigger channel 2, otherwise 0.
  • channel3: 1 to trigger channel 3, otherwise 0.
  • channel4: 1 to trigger channel 4, otherwise 0.
Raises:
  • PulsePalError: If a channel number is not 1-4, or a flag is not 0 or 1. Nothing is triggered.
def sd_settings(self, settings_file_name, op):
1183    def sd_settings(self, settings_file_name, op):
1184        """Save, load, or delete a settings file on the microSD card.
1185
1186        A settings file holds a complete Pulse Pal program, so that it
1187        can be recalled later from software or from the device's front
1188        panel. Loading a file also refreshes the local copy of the
1189        parameters, via `PulsePalDevice.sync_from_device`.
1190
1191        ```python
1192        P.sd_settings("MyProtocol.pps", "save")
1193        ```
1194
1195        Args:
1196            settings_file_name: Settings file name, at most 15 ASCII
1197                characters including the required `.pps` extension.
1198            op: `"save"`, `"load"`, or `"delete"`.
1199
1200        Raises:
1201            PulsePalError: If the file name has no `.pps` extension or
1202                is too long, `op` is not one of the three operations, or
1203                the device does not acknowledge the command. A load that
1204                fails leaves the device on its own default parameters
1205                (not the ones `set_default_params` sets), and the local
1206                copy is read back from the device before the error is
1207                raised.
1208        """
1209        if ".pps" not in settings_file_name:
1210            raise PulsePalError(
1211                "Error: The file name must have a valid .pps extension."
1212            )
1213        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1214        try:
1215            op_byte = op_byte_by_name[str(op).lower()]
1216        except KeyError as exc:
1217            raise PulsePalError(
1218                "File op must be: 'save', 'load' or 'delete'."
1219            ) from exc
1220
1221        filename_bytes = settings_file_name.encode("ascii")
1222        if len(filename_bytes) > 15:
1223            raise PulsePalError("settings_file_name is too long.")
1224        self._write_serial(
1225            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1226            "uint8",
1227            list(filename_bytes),
1228            "uint8",
1229        )
1230        if self.info.firmware_version > 21:
1231            # Sent after the file operation has finished. A refused load leaves the device on
1232            # its default parameters, so the local copy is read back first.
1233            self._read_ack(
1234                "sd_settings()",
1235                on_refusal=self.sync_from_device if op_byte == 2 else None,
1236            )
1237        elif op_byte == 2:
1238            time.sleep(0.1)  # Firmware v21 does not acknowledge, so allow time for the load
1239        if op_byte == 2:
1240            self.sync_from_device()

Save, load, or delete a settings file on the microSD card.

A settings file holds a complete Pulse Pal program, so that it can be recalled later from software or from the device's front panel. Loading a file also refreshes the local copy of the parameters, via PulsePalDevice.sync_from_device.

P.sd_settings("MyProtocol.pps", "save")
Arguments:
  • settings_file_name: Settings file name, at most 15 ASCII characters including the required .pps extension.
  • op: "save", "load", or "delete".
Raises:
  • PulsePalError: If the file name has no .pps extension or is too long, op is not one of the three operations, or the device does not acknowledge the command. A load that fails leaves the device on its own default parameters (not the ones set_default_params sets), and the local copy is read back from the device before the error is raised.
def stop(self, channels=None):
1242    def stop(self, channels=None):
1243        """Stop pulse trains currently playing on the device.
1244
1245        Every output channel returns to its
1246        `PulsePalDevice.resting_voltage`.
1247
1248        Args:
1249            channels (list or tuple, optional): A list of channels to stop, e.g.,
1250                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single
1251                channel number. NumPy integers and arrays are accepted. Default is
1252                None, which stops all channels. (Requires firmware v22+)
1253
1254        Raises:
1255            PulsePalError: If a channel number is not 1-4.
1256        """
1257        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1258
1259        if channels is not None:
1260            if self.info.firmware_version < 22:
1261                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1262
1263            bit_code = 0
1264            for ch in self._output_channel_numbers(channels, "stop()"):
1265                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1266                bit_code |= 1 << (ch - 1)
1267
1268        # Send
1269        if self.info.firmware_version < 22:
1270            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1271        else:
1272            self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")

Stop pulse trains currently playing on the device.

Every output channel returns to its PulsePalDevice.resting_voltage.

Arguments:
  • channels (list or tuple, optional): A list of channels to stop, e.g., [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single channel number. NumPy integers and arrays are accepted. Default is None, which stops all channels. (Requires firmware v22+)
Raises:
  • PulsePalError: If a channel number is not 1-4.
def format_microsd(self, timeout=30):
1274    def format_microsd(self, timeout=30):
1275        """Format the device's microSD card.
1276
1277        Erases every settings file stored on the device and resets its
1278        parameters to the defaults. The user is prompted at the console
1279        to confirm before anything is erased.
1280
1281        Requires Pulse Pal hardware v3 or newer.
1282
1283        Args:
1284            timeout: Seconds to wait for the device to report that
1285                formatting has finished.
1286
1287        Returns:
1288            `None` once the card has been formatted, or an empty string
1289            if the user declines the confirmation prompt.
1290
1291        Raises:
1292            PulsePalError: If the connected hardware is older than v3, if
1293                the device reports that formatting failed, or if it does
1294                not report a result within `timeout` seconds.
1295        """
1296        if self.info.hardware_version < 3:
1297            raise PulsePalError(
1298                "format_microsd() requires hardware v3 or newer."
1299            )
1300
1301        print("*** Pulse Pal microSD Formatter ***")
1302        print("This will format Pulse Pal's microSD card,")
1303        print("erase all settings files on the device")
1304        print("and reset all parameters to defaults.")
1305
1306        reply = input("Do you want to continue (y/n)")
1307
1308        if reply.strip().lower() != "y":
1309            print("Choice confirmed - microSD Card NOT formatted.")
1310            return ""
1311
1312        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1313
1314        # The device replies with lines of status text, the last of which
1315        # contains "!", and then a confirm byte (1 if the card was formatted,
1316        # 0 if not). The confirm byte is sent after the device has reloaded
1317        # its default parameters, so it can arrive well after the text. It
1318        # must be read here: left in the buffer, it would be taken as the
1319        # reply to the next command, and every reply after that would be
1320        # read one byte late.
1321        start = time.time()
1322        message = bytearray()
1323        flag_index = -1
1324        line_end = -1
1325
1326        while time.time() - start < timeout:
1327            n_waiting = self.bytes_available()
1328            if n_waiting:
1329                message.extend(self.port.read(n_waiting))
1330                flag_index = message.find(b"!")
1331                if flag_index >= 0:
1332                    line_end = message.find(b"\n", flag_index)
1333                if line_end >= 0 and len(message) > line_end + 1:
1334                    break
1335            time.sleep(0.01)
1336        if line_end < 0 or len(message) <= line_end + 1:
1337            raise PulsePalError(
1338                "Error: Pulse Pal did not report the result of formatting "
1339                f"its microSD card within {timeout} s."
1340            )
1341        confirm = message[line_end + 1]
1342
1343        text = bytes(message[:flag_index]).decode(
1344            "ascii", errors="replace"
1345        ).rstrip()
1346        if text:
1347            print(text)
1348
1349        self.set_default_params()
1350        if confirm != 1:
1351            raise PulsePalError(
1352                "Error: Pulse Pal could not format its microSD card."
1353            )
1354        return None

Format the device's microSD card.

Erases every settings file stored on the device and resets its parameters to the defaults. The user is prompted at the console to confirm before anything is erased.

Requires Pulse Pal hardware v3 or newer.

Arguments:
  • timeout: Seconds to wait for the device to report that formatting has finished.
Returns:

None once the card has been formatted, or an empty string if the user declines the confirmation prompt.

Raises:
  • PulsePalError: If the connected hardware is older than v3, if the device reports that formatting failed, or if it does not report a result within timeout seconds.
def gui(self, block=None, theme=None):
1356    def gui(self, block=None, theme=None):
1357        """Open the Pulse Pal parameter GUI, or focus an open one.
1358
1359        The GUI edits its own copy of the parameters, and loads them to
1360        the device when its 'Load to Device' button is clicked. The
1361        window closes automatically when the device is closed or
1362        deleted.
1363
1364        Calling this while the GUI is already open focuses the existing
1365        window rather than opening a second one.
1366
1367        Args:
1368            block: If `True`, the call returns when the GUI is closed. If
1369                `False`, the call returns immediately, and the host
1370                application must run the Tk event loop. If `None`, the
1371                GUI blocks only when the host does not already provide a
1372                Tk event loop, e.g. when launched from a script.
1373            theme: `"light"` or `"dark"` to select the color theme, or
1374                `None` to match the desktop theme. Passing a theme to an
1375                already-open GUI recolors it in place.
1376
1377        Returns:
1378            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1379
1380        Raises:
1381            ValueError: If the theme name is not recognized.
1382        """
1383        gui = getattr(self, "_gui", None)
1384        if gui is not None and not gui.is_closed:
1385            if theme is not None:
1386                gui.set_theme(theme)
1387            gui.focus()
1388            return gui
1389
1390        try:
1391            from .PulsePalGUI import PulsePalGUI
1392        except ImportError:
1393            from PulsePalGUI import PulsePalGUI
1394
1395        gui = PulsePalGUI(self, theme=theme)
1396        self._gui = gui
1397        gui.start(block=block)
1398        return gui

Open the Pulse Pal parameter GUI, or focus an open one.

The GUI edits its own copy of the parameters, and loads them to the device when its 'Load to Device' button is clicked. The window closes automatically when the device is closed or deleted.

Calling this while the GUI is already open focuses the existing window rather than opening a second one.

Arguments:
  • block: If True, the call returns when the GUI is closed. If False, the call returns immediately, and the host application must run the Tk event loop. If None, the GUI blocks only when the host does not already provide a Tk event loop, e.g. when launched from a script.
  • theme: "light" or "dark" to select the color theme, or None to match the desktop theme. Passing a theme to an already-open GUI recolors it in place.
Returns:

The PulsePalGUI.PulsePalGUI instance driving the window.

Raises:
  • ValueError: If the theme name is not recognized.
def close(self, send_disconnect=True):
1400    def close(self, send_disconnect=True):
1401        """Close the connection to the device, and the GUI if open.
1402
1403        Safe to call more than once; later calls do nothing. Called
1404        automatically when leaving a `with` block and when the object is
1405        garbage collected.
1406
1407        Args:
1408            send_disconnect: If `True`, tell the device that the client
1409                is disconnecting before closing the port. Set to `False`
1410                when the device is in an unknown state, such as after a
1411                failed handshake.
1412        """
1413        gui = getattr(self, "_gui", None)
1414        self._gui = None
1415        if gui is not None:
1416            try:
1417                gui.close()
1418            except Exception:
1419                # Cleanup must not raise; Tk may already be torn down
1420                pass
1421
1422        if getattr(self, "_closed", True):
1423            return
1424
1425        try:
1426            if send_disconnect and self.port and self.port.is_open:
1427                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1428        finally:
1429            if self.port and self.port.is_open:
1430                self.port.close()
1431            self._closed = True

Close the connection to the device, and the GUI if open.

Safe to call more than once; later calls do nothing. 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 is in an unknown state, such as after a failed handshake.
def bytes_available(self):
1433    def bytes_available(self):
1434        """Return the number of bytes waiting in the serial read buffer.
1435
1436        Returns:
1437            Count of bytes that can be read without blocking.
1438        """
1439        return self.port.in_waiting

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

Returns:

Count of bytes that can be read without blocking.

@dataclass
class DeviceInfo:
118@dataclass
119class DeviceInfo:
120    """Properties of the connected Pulse Pal device.
121
122    An instance is created for each connection and populated during the
123    handshake in `PulsePalDevice.__init__`. It is available as
124    `PulsePalDevice.info`. Devices running firmware v21 report only a
125    firmware version, so the remaining fields are filled in with the
126    known values for Pulse Pal hardware v2.
127
128    ```python
129    print(P.info.firmware_version)
130    ```
131    """
132
133    output_parameter_names: list = None
134    """Output parameter names, ordered by parameter code.
135
136    The position of a name in this list, plus 1, is the parameter code
137    the device expects. Any name here is valid as the `param_name`
138    argument of `PulsePalDevice.set_output_param`, and is also the name
139    of the matching parameter array attribute on `PulsePalDevice`.
140    """
141
142    trigger_parameter_names: list = None
143    """Trigger parameter names, accepted by
144    `PulsePalDevice.set_trigger_param`."""
145
146    firmware_version: int = None
147    """Firmware version running on the connected device."""
148
149    hardware_version: int = None
150    """Hardware revision of the connected device, e.g. `2` or `3`.
151
152    Reported by the device on firmware v22 and newer; assumed to be `2`
153    on older firmware.
154    """
155
156    max_custom_pulses: int = None
157    """Maximum number of pulses in a single custom pulse train."""
158
159    n_custom_pulse_trains: int = None
160    """Number of custom pulse trains the device can store."""
161
162    cycle_frequency: float = None
163    """Update frequency of the device's hardware timer, in Hz.
164
165    All time parameters are rounded to a whole number of these cycles,
166    so this sets the timing resolution of the device.
167    """
168
169    cycle_period_us: float = None
170    """Update period of the device's hardware timer, in microseconds."""
171
172    min_pulse_width_us: float = None
173    """Shortest pulse phase, inter-pulse interval and pulse train duration,
174    and shortest time between custom pulses, in microseconds.
175
176    Two timer cycles: a trigger channel reads its input once per cycle, so
177    a pulse must last two cycles to be detected reliably.
178    """

Properties of the connected Pulse Pal device.

An instance is created for each connection and populated during the handshake in PulsePalDevice.__init__. It is available as PulsePalDevice.info. Devices running firmware v21 report only a firmware version, so the remaining fields are filled in with the known values for Pulse Pal hardware v2.

print(P.info.firmware_version)
DeviceInfo( output_parameter_names: list = None, trigger_parameter_names: list = None, firmware_version: int = None, hardware_version: int = None, max_custom_pulses: int = None, n_custom_pulse_trains: int = None, cycle_frequency: float = None, cycle_period_us: float = None, min_pulse_width_us: float = None)
output_parameter_names: list = None

Output parameter names, ordered by parameter code.

The position of a name in this list, plus 1, is the parameter code the device expects. Any name here is valid as the param_name argument of PulsePalDevice.set_output_param, and is also the name of the matching parameter array attribute on PulsePalDevice.

trigger_parameter_names: list = None

Trigger parameter names, accepted by PulsePalDevice.set_trigger_param.

firmware_version: int = None

Firmware version running on the connected device.

hardware_version: int = None

Hardware revision of the connected device, e.g. 2 or 3.

Reported by the device on firmware v22 and newer; assumed to be 2 on older firmware.

max_custom_pulses: int = None

Maximum number of pulses in a single custom pulse train.

n_custom_pulse_trains: int = None

Number of custom pulse trains the device can store.

cycle_frequency: float = None

Update frequency of the device's hardware timer, in Hz.

All time parameters are rounded to a whole number of these cycles, so this sets the timing resolution of the device.

cycle_period_us: float = None

Update period of the device's hardware timer, in microseconds.

min_pulse_width_us: float = None

Shortest pulse phase, inter-pulse interval and pulse train duration, and shortest time between custom pulses, in microseconds.

Two timer cycles: a trigger channel reads its input once per cycle, so a pulse must last two cycles to be detected reliably.

class PulsePalError(builtins.Exception):
108class PulsePalError(Exception):
109    """Raised when Pulse Pal communication or configuration fails.
110
111    This covers serial reads that time out, short serial writes, missing
112    acknowledgement bytes, unknown parameter names, values that do not
113    fit the datatype expected by the device, and operations that the
114    connected firmware or hardware revision does not support.
115    """

Raised when Pulse Pal communication or configuration fails.

This covers serial reads that time out, short serial writes, missing acknowledgement bytes, unknown parameter names, values that do not fit the datatype expected by the device, and operations that the connected firmware or hardware revision does not support.