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.

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). Enumerated parameters are integers, and their meanings are given with each attribute below.

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.
  43
  44## Units
  45
  46Voltages are in volts in the range [-10, 10]. Times are in seconds, and
  47are rounded to the nearest cycle of the device's hardware timer (see
  48`DeviceInfo.cycle_frequency`). Enumerated parameters are integers, and
  49their meanings are given with each attribute below.
  50
  51## Further reading
  52
  53- Parameter guide:
  54  https://sites.google.com/site/pulsepalwiki/parameter-guide
  55- Serial interface and general documentation:
  56  https://sites.google.com/site/pulsepalwiki/
  57
  58## License
  59
  60This file is part of the Sanworks PulsePal repository.
  61Copyright (C) Sanworks LLC, Rochester, New York, USA
  62
  63This program is free software: you can redistribute it and/or modify
  64it under the terms of the GNU General Public License as published by
  65the Free Software Foundation, version 3.
  66
  67This program is distributed WITHOUT ANY WARRANTY and without even the
  68implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  69See the GNU General Public License for more details.
  70
  71You should have received a copy of the GNU General Public License
  72along with this program. If not, see <http://www.gnu.org/licenses/>.
  73"""
  74
  75from decimal import Decimal
  76from dataclasses import dataclass
  77import numbers
  78import struct
  79import time
  80
  81import numpy as np
  82import serial
  83import serial.tools.list_ports
  84
  85__all__ = ["PulsePalDevice", "DeviceInfo", "PulsePalError"]
  86__docformat__ = "google"
  87
  88
  89class PulsePalError(Exception):
  90    """Raised when Pulse Pal communication or configuration fails.
  91
  92    This covers serial reads that time out, short serial writes, missing
  93    acknowledgement bytes, unknown parameter names, values that do not
  94    fit the datatype expected by the device, and operations that the
  95    connected firmware or hardware revision does not support.
  96    """
  97
  98
  99@dataclass
 100class DeviceInfo:
 101    """Properties of the connected Pulse Pal device.
 102
 103    An instance is created for each connection and populated during the
 104    handshake in `PulsePalDevice.__init__`. It is available as
 105    `PulsePalDevice.info`. Devices running firmware v21 report only a
 106    firmware version, so the remaining fields are filled in with the
 107    known values for Pulse Pal hardware v2.
 108
 109    ```python
 110    print(P.info.firmware_version)
 111    ```
 112    """
 113
 114    output_parameter_names: list = None
 115    """Output parameter names, ordered by parameter code.
 116
 117    The position of a name in this list, plus 1, is the parameter code
 118    the device expects. Any name here is valid as the `param_name`
 119    argument of `PulsePalDevice.set_output_param`, and is also the name
 120    of the matching parameter array attribute on `PulsePalDevice`.
 121    """
 122
 123    trigger_parameter_names: list = None
 124    """Trigger parameter names, accepted by
 125    `PulsePalDevice.set_trigger_param`."""
 126
 127    firmware_version: int = None
 128    """Firmware version running on the connected device."""
 129
 130    hardware_version: int = None
 131    """Hardware revision of the connected device, e.g. `2` or `3`.
 132
 133    Reported by the device on firmware v22 and newer; assumed to be `2`
 134    on older firmware.
 135    """
 136
 137    max_custom_pulses: int = None
 138    """Maximum number of pulses in a single custom pulse train."""
 139
 140    n_custom_pulse_trains: int = None
 141    """Number of custom pulse trains the device can store."""
 142
 143    cycle_frequency: float = None
 144    """Update frequency of the device's hardware timer, in Hz.
 145
 146    All time parameters are rounded to a whole number of these cycles,
 147    so this sets the timing resolution of the device.
 148    """
 149
 150    cycle_period_us: float = None
 151    """Update period of the device's hardware timer, in microseconds."""
 152
 153
 154class PulsePalDevice:
 155    """A class to control a Pulse Pal device on a USB serial port.
 156
 157    Creating an instance opens the serial port, exchanges a handshake
 158    with the device, verifies that its firmware is supported, reads the
 159    device properties into `PulsePalDevice.info`, and programs the
 160    device with the default parameters.
 161
 162    ```python
 163    from PulsePal import PulsePalDevice
 164
 165    P = PulsePalDevice("COM3")
 166    P.set_output_param("phase1_voltage", 1, 5)
 167    P.trigger(1)
 168    P.close()
 169    ```
 170
 171    Here, replace "COM3" with Pulse Pal's USB serial port name.
 172    To view a list of available ports, use
 173    PulsePalDevice.serialportlist()
 174    Pulse Pal's port may not be visible if it is connected to another
 175    instance of PulsePalDevice or an external application. Use
 176    PulsePalDevice.serialportlist('all') to view all ports.
 177    If you see multiple available ports, disconnect Pulse Pal's USB plug
 178    and re-run serialportlist(). Notice which port disappears from the list.
 179
 180    PulsePalDevice is also a context manager, which closes the connection on
 181    exit even if an error is raised:
 182
 183    ```python
 184    with PulsePalDevice("COM3") as P:
 185        P.trigger(1)
 186    ```
 187
 188    The attributes below named after Pulse Pal parameters are the local
 189    copy of the device's program. Each is a five element list indexed by
 190    channel number, with index 0 unused. This way channels are addressed
 191    by the index on the device, e.g. trigger channels 1-2 and output
 192    channels 1-4. Assigning parameters does not update the device until
 193    `PulsePalDevice.sync_to_device` is called;
 194    `PulsePalDevice.set_output_param` programs one parameter right away.
 195    """
 196
 197    port: "serial.Serial"
 198    """The open `serial.Serial` port connected to the device."""
 199
 200    info: DeviceInfo
 201    """Properties of the connected device. See `DeviceInfo`."""
 202
 203    is_biphasic: list
 204    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 205
 206    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 207    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 208    phase 2.
 209    """
 210
 211    phase1_voltage: list
 212    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 213
 214    phase2_voltage: list
 215    """Voltage of the second phase of each pulse, in volts [-10, 10].
 216
 217    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 218    """
 219
 220    resting_voltage: list
 221    """Voltage held between pulses, in volts [-10, 10]."""
 222
 223    phase1_duration: list
 224    """Duration of the first phase of each pulse, in seconds."""
 225
 226    inter_phase_interval: list
 227    """Interval between the two phases of a biphasic pulse, in seconds.
 228
 229    The channel rests at `PulsePalDevice.resting_voltage` during the
 230    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 231    """
 232
 233    phase2_duration: list
 234    """Duration of the second phase of each pulse, in seconds.
 235
 236    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 237    """
 238
 239    inter_pulse_interval: list
 240    """Interval from the end of one pulse to the onset of the next, in
 241    seconds."""
 242
 243    burst_duration: list
 244    """Duration of each burst of pulses, in seconds.
 245
 246    Set to `0` to disable bursts, so that pulses continue for the whole
 247    pulse train.
 248    """
 249
 250    inter_burst_interval: list
 251    """Interval between bursts of pulses, in seconds.
 252
 253    The channel rests at `PulsePalDevice.resting_voltage` between
 254    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 255    """
 256
 257    pulse_train_duration: list
 258    """Total duration of the pulse train, in seconds."""
 259
 260    pulse_train_delay: list
 261    """Delay from the trigger to the onset of the pulse train, in
 262    seconds."""
 263
 264    link_trigger_channel1: list
 265    """Whether each output channel is linked to trigger channel 1.
 266
 267    `1` links the output channel to trigger channel 1, `0` unlinks it.
 268    """
 269
 270    link_trigger_channel2: list
 271    """Whether each output channel is linked to trigger channel 2.
 272
 273    `1` links the output channel to trigger channel 2, `0` unlinks it.
 274    """
 275
 276    custom_train_id: list
 277    """Custom pulse train played by each output channel.
 278
 279    `0` plays the parametrically defined train. `1` or higher plays the
 280    matching custom train, previously loaded with
 281    `PulsePalDevice.send_custom_pulse_train` or
 282    `PulsePalDevice.send_custom_waveform`.
 283    """
 284
 285    custom_train_target: list
 286    """What the timestamps of a custom train mark.
 287
 288    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 289    is the onset of a burst of pulses.
 290    """
 291
 292    custom_train_loop: list
 293    """Whether a custom train repeats.
 294
 295    `1` loops the custom train until
 296    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 297    once.
 298    """
 299
 300    playback_mode: list
 301    """Continuous playback mode of parametric pulse trains after being triggered
 302
 303    - `0` plays the pulse train once until pulse_train_duration seconds
 304    - `1` plays the pulse train indefinitely, ignoring pulse_train_duration
 305    
 306    """
 307
 308    trigger_mode: list
 309    """Response of each trigger channel to an incoming TTL pulse.
 310
 311    Three element list indexed by trigger channel, with index 0 unused.
 312    Elements 1 and 2 control the respective channels on the device.
 313    Their values can be:
 314
 315    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 316      during the train are ignored.
 317    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 318    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 319    """
 320
 321    _CURRENT_FIRMWARE_VERSION = 22
 322
 323    _OP_MENU_BYTE = 213
 324    _HANDSHAKE_OPCODE = 72
 325    _HANDSHAKE_RESPONSE = 75
 326    _DAC_BITMAX = 65535
 327    _OLDEST_FIRMWARE_SUPPORTED = 21
 328
 329    _OUTPUT_PARAMETER_NAMES = (
 330        "is_biphasic",
 331        "phase1_voltage",
 332        "phase2_voltage",
 333        "phase1_duration",
 334        "inter_phase_interval",
 335        "phase2_duration",
 336        "inter_pulse_interval",
 337        "burst_duration",
 338        "inter_burst_interval",
 339        "pulse_train_duration",
 340        "pulse_train_delay",
 341        "link_trigger_channel1",
 342        "link_trigger_channel2",
 343        "custom_train_id",
 344        "custom_train_target",
 345        "custom_train_loop",
 346        "playback_mode",
 347        "resting_voltage",
 348    )
 349    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 350
 351    _OUTPUT_PARAMETER_ATTRS = {
 352        1: "is_biphasic",
 353        2: "phase1_voltage",
 354        3: "phase2_voltage",
 355        4: "phase1_duration",
 356        5: "inter_phase_interval",
 357        6: "phase2_duration",
 358        7: "inter_pulse_interval",
 359        8: "burst_duration",
 360        9: "inter_burst_interval",
 361        10: "pulse_train_duration",
 362        11: "pulse_train_delay",
 363        12: "link_trigger_channel1",
 364        13: "link_trigger_channel2",
 365        14: "custom_train_id",
 366        15: "custom_train_target",
 367        16: "custom_train_loop",
 368        17: "resting_voltage",
 369        18: "playback_mode",
 370    }
 371    _ENDIANNESS = "<"
 372    _STRUCT_FORMATS = {
 373        "uint8": "B",
 374        "int8": "b",
 375        "char": "c",
 376        "uint16": "H",
 377        "int16": "h",
 378        "uint32": "I",
 379        "int32": "i",
 380        "single": "f",
 381        "double": "d",
 382    }
 383    _TYPE_RANGES = {
 384        "uint8": (0, 2**8 - 1),
 385        "int8": (-(2**7), 2**7 - 1),
 386        "uint16": (0, 2**16 - 1),
 387        "int16": (-(2**15), 2**15 - 1),
 388        "uint32": (0, 2**32 - 1),
 389        "int32": (-(2**31), 2**31 - 1),
 390    }
 391
 392    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 393        """Open a connection to a Pulse Pal device.
 394
 395        Opens the serial port, exchanges the handshake, verifies the
 396        firmware version, reads the device properties into
 397        `PulsePalDevice.info`, and programs the device with the default
 398        parameters.
 399
 400        Args:
 401            port_name: USB serial port for the Pulse Pal device, such as
 402                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 403            baud_rate: Serial baud rate.
 404            timeout: Serial read timeout, in seconds.
 405
 406        Raises:
 407            PulsePalError: If the device does not return the expected
 408                handshake, or its firmware is older than v21, or its
 409                firmware is newer than this module supports.
 410            serial.SerialException: If the serial port cannot be opened.
 411        """
 412        self.info = DeviceInfo()
 413        self._gui = None
 414        self.port = serial.Serial(
 415            port_name,
 416            baud_rate,
 417            timeout=timeout,
 418            rtscts=True,
 419        )
 420        self._closed = False
 421        self._dac_bit_max = self._to_decimal(0)
 422        self.info.firmware_version = None
 423        self.info.hardware_version = None
 424        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 425        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 426
 427        self._write_serial(
 428            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 429            "uint8",
 430        )
 431        handshake = self._read_serial(1, "uint8")
 432        if handshake != self._HANDSHAKE_RESPONSE:
 433            self.close(send_disconnect=False)
 434            raise PulsePalError(
 435                "Error: incorrect handshake returned. Expected "
 436                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 437            )
 438
 439        firmware_version = self._read_serial(1, "uint32")
 440        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 441            raise PulsePalError(
 442                "Error: Old firmware detected, v"
 443                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 444                "newer is required."
 445            )
 446        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 447            raise PulsePalError(
 448                "Error: Future firmware detected, v"
 449                f"{firmware_version}. Please update PulsePal.py or downgrade "
 450                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 451            )
 452        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 453            print(
 454                "Old firmware detected, v"
 455                f"{firmware_version}. This firmware is supported. Update to v"
 456                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 457            )
 458        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 459        self.info.firmware_version = firmware_version
 460
 461        if self.info.firmware_version > 21:
 462            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 463            self.info.hardware_version = self._read_serial(1, "uint8")
 464            self.info.cycle_period_us = self._read_serial(1, "uint32")
 465            self.info.cycle_frequency = 1 / (
 466                self.info.cycle_period_us / 1000000
 467            )
 468            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 469            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 470        else:
 471            self.info.hardware_version = 2
 472            self.info.cycle_period_us = 50
 473            self.info.cycle_frequency = 20000
 474            self.info.n_custom_pulse_trains = 2
 475            self.info.max_custom_pulses = 5000
 476
 477        # Client name op + "PYTHON" in ASCII.
 478        self._write_serial(
 479            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 480            "uint8",
 481        )
 482
 483        self.set_default_params()
 484        self.sync_to_device()
 485
 486    @staticmethod
 487    def serialportlist(ports_to_list="available"):
 488        """Return the names of the USB serial ports on this computer.
 489
 490        Called on the class, without connecting to a device, to find the
 491        port name to pass to `PulsePalDevice`:
 492
 493        ```python
 494        from PulsePal import PulsePalDevice
 495
 496        ports = PulsePalDevice.serialportlist()
 497        P = PulsePalDevice(ports[0])
 498        ```
 499
 500        Args:
 501            ports_to_list: `available` to list only the ports that are
 502                not already in use, or `all` to list every USB serial
 503                port. Not case sensitive.
 504
 505        Returns:
 506            Sorted list of port names, such as `["COM3", "COM7"]` on
 507            Windows or `["/dev/ttyACM0"]` on Linux.
 508
 509        Raises:
 510            PulsePalError: If `ports_to_list` is not `available` or
 511                `all`.
 512        """
 513        if not isinstance(ports_to_list, str):
 514            raise PulsePalError(
 515                "serialportlist() takes 'available' or 'all'."
 516            )
 517        mode = ports_to_list.lower()
 518        if mode not in ("available", "all"):
 519            raise PulsePalError(
 520                f"Unknown port list type: {ports_to_list}. "
 521                "Use 'available' or 'all'."
 522            )
 523
 524        port_names = []
 525        for port_info in serial.tools.list_ports.comports():
 526            is_usb = port_info.vid is not None or "USB" in (
 527                port_info.hwid or ""
 528            ).upper()
 529            if not is_usb:
 530                continue
 531            if mode == "available" and not PulsePalDevice._port_is_free(
 532                port_info.device
 533            ):
 534                continue
 535            port_names.append(port_info.device)
 536        return sorted(port_names)
 537
 538    @staticmethod
 539    def _port_is_free(port_name):
 540        """Return True if the port is not already open in another program."""
 541        port = serial.Serial()
 542        port.port = port_name
 543        # Leaving the control lines low avoids resetting boards that
 544        # reset on DTR while the port is probed.
 545        port.dtr = False
 546        port.rts = False
 547        try:
 548            port.open()
 549        except (serial.SerialException, OSError):
 550            return False
 551        port.close()
 552        return True
 553
 554    def set_default_params(self):
 555        """Reset the local copy of all parameters to their defaults.
 556
 557        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 558        1 second, on all four output channels, linked to trigger channel
 559        1 in normal trigger mode.
 560
 561        This updates only the local copy. Call
 562        `PulsePalDevice.sync_to_device` to program the device with them.
 563        """
 564        nan = float("nan")
 565        self.is_biphasic = [nan, 0, 0, 0, 0]
 566        self.phase1_voltage = [nan, 5, 5, 5, 5]
 567        self.phase2_voltage = [nan, -5, -5, -5, -5]
 568        self.resting_voltage = [nan, 0, 0, 0, 0]
 569        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 570        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 571        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 572        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 573        self.burst_duration = [nan, 0, 0, 0, 0]
 574        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 575        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 576        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 577        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 578        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 579        self.custom_train_id = [nan, 0, 0, 0, 0]
 580        self.custom_train_target = [nan, 0, 0, 0, 0]
 581        self.custom_train_loop = [nan, 0, 0, 0, 0]
 582        self.playback_mode = [nan, 0, 0, 0, 0]
 583        self.trigger_mode = [nan, 0, 0]
 584
 585    def set_voltage(self, channel, voltage):
 586        """Set an output channel to a fixed voltage.
 587
 588        The channel holds the voltage until it is set again or until a
 589        pulse train is triggered on it.
 590
 591        Args:
 592            channel: Output channel number, 1-4.
 593            voltage: Voltage to set, in volts [-10, 10].
 594
 595        Raises:
 596            PulsePalError: If the device does not acknowledge the
 597                command.
 598        """
 599        voltage_bits = self._volts_to_bits(voltage)
 600        self._write_serial(
 601            (self._OP_MENU_BYTE, 79, channel),
 602            "uint8",
 603            voltage_bits,
 604            "uint16",
 605        )
 606        self._read_ack("set_fixed_voltage()")
 607
 608    def set_calibration(self, channel, voltage_offset):
 609        """Calibrate the zero code of an output channel.
 610
 611        The offset is added to every voltage the channel produces, to
 612        correct for DAC offset error. It is stored in the device's
 613        EEPROM and reloaded on boot, so it only needs to be set once.
 614
 615        Requires Pulse Pal hardware v3 or newer.
 616
 617        Args:
 618            channel: Output channel number, 1-4.
 619            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 620
 621        Raises:
 622            PulsePalError: If the connected hardware is older than v3,
 623                or the device does not acknowledge the command.
 624            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 625                outside [-0.1, 0.1].
 626        """
 627        if self.info.hardware_version < 3:
 628            raise PulsePalError(
 629                "set_calibration() requires hardware v3 or newer."
 630            )
 631        if channel not in (1, 2, 3, 4):
 632            raise ValueError("channel must be 1, 2, 3 or 4")
 633        if voltage_offset < -0.1 or voltage_offset > 0.1:
 634            raise ValueError(
 635                "voltage_offset for zero code calibration must be in range "
 636                "[-0.1, 0.1]"
 637            )
 638        voltage_bits = voltage_offset * (1 / (20 / 65536))
 639        self._write_serial(
 640            (self._OP_MENU_BYTE, 96, channel - 1),
 641            "uint8",
 642            voltage_bits,
 643            "int16",
 644        )
 645        self._read_ack("set_calibration()")
 646
 647    def set_output_param(self, param_name, channel, value):
 648        """Program a single output channel parameter on the device.
 649
 650        The local copy of the parameter is updated to match, so a later
 651        `PulsePalDevice.sync_to_device` will not undo the change.
 652
 653        ```python
 654        P.set_output_param("is_biphasic", 1, 1)
 655        P.set_output_param("phase1_voltage", 1, 10)
 656        P.set_output_param(3, 1, -10)   # same, by param code
 657        ```
 658
 659        Args:
 660            param_name: Parameter name, as listed in
 661                `DeviceInfo.output_parameter_names`, or its integer
 662                parameter code.
 663            channel: Output channel number, 1-4.
 664            value: Value to set. Units are volts for voltage parameters,
 665                seconds for time parameters, and integers for enumerated
 666                parameters. See the attributes of `PulsePalDevice` for
 667                the meaning of each parameter.
 668
 669        Raises:
 670            PulsePalError: If the parameter name is not recognized, the
 671                value does not fit the datatype the device expects, or
 672                the device does not acknowledge the command.
 673        """
 674        original_value = value
 675        param_code = self._get_output_param_code(param_name)
 676
 677        if param_code in (2, 3, 17):
 678            value = self._volts_to_bits(value)
 679            self._write_serial(
 680                (self._OP_MENU_BYTE, 74, param_code, channel),
 681                "uint8",
 682                value,
 683                "uint16",
 684            )
 685        elif 4 <= param_code <= 11:
 686            self._write_serial(
 687                (self._OP_MENU_BYTE, 74, param_code, channel),
 688                "uint8",
 689                self._seconds_to_cycles(value),
 690                "uint32",
 691            )
 692        else:
 693            self._write_serial(
 694                (self._OP_MENU_BYTE, 74, param_code, channel, value),
 695                "uint8",
 696            )
 697
 698        self._read_ack("program_output_channel_param()")
 699        self._set_output_param_value(param_code, channel, original_value)
 700
 701    def set_trigger_param(self, param_name, channel, value):
 702        """Program a single trigger channel parameter on the device.
 703
 704        The local copy of the parameter is updated to match, so a later
 705        `PulsePalDevice.sync_to_device` will not undo the change.
 706
 707        ```python
 708        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 709        ```
 710
 711        Args:
 712            param_name: Parameter name, as listed in
 713                `DeviceInfo.trigger_parameter_names`, or its integer
 714                parameter code.
 715            channel: Trigger channel number, 1-2.
 716            value: Value to set. See `PulsePalDevice.trigger_mode` for
 717                the trigger modes.
 718
 719        Raises:
 720            PulsePalError: If the parameter name is not recognized, the
 721                value does not fit the datatype the device expects, or
 722                the device does not acknowledge the command.
 723        """
 724        original_value = value
 725        param_code = self._get_trigger_param_code(param_name)
 726
 727        self._write_serial(
 728            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 729            "uint8",
 730        )
 731        self._read_ack("program_trigger_channel_param()")
 732
 733        if param_code in (1, 128):
 734            self.trigger_mode[channel] = original_value
 735
 736    def sync_to_device(self):
 737        """Program the device with the local copy of all parameters.
 738
 739        Call this after assigning to the parameter array attributes, to
 740        send every output and trigger parameter to the device in a
 741        single transaction.
 742
 743        ```python
 744        P.phase1_voltage[1:5] = [5] * 4
 745        P.sync_to_device()
 746        ```
 747
 748        Raises:
 749            PulsePalError: If the device does not acknowledge the
 750                command.
 751        """
 752        # _sync_all_params() for firmware v22+ uses the newer packed sync
 753        # opcode (92). _sync_all_params_legacy() uses the less efficient
 754        # legacy packed sync opcode (73).
 755        if self.info.firmware_version > 21:
 756            self._sync_all_params()
 757        else:
 758            self._sync_all_params_legacy()
 759        self._read_ack("sync_to_device()")
 760
 761    def sync_from_device(self):
 762        """Read all parameters from the device into the local copy.
 763
 764        Overwrites every parameter array attribute with the program
 765        currently stored on the device. Useful after the device has been
 766        reprogrammed from its thumb joystick.
 767
 768        Requires firmware v22 or newer.
 769
 770        Raises:
 771            PulsePalError: If the connected firmware is older than v22,
 772                or the device does not return the full parameter set.
 773        """
 774        self._require_firmware(22, "sync_from_device()")
 775        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 776        for attr_name in (
 777                "phase1_duration",
 778                "inter_phase_interval",
 779                "phase2_duration",
 780                "inter_pulse_interval",
 781                "burst_duration",
 782                "inter_burst_interval",
 783                "pulse_train_duration",
 784                "pulse_train_delay",
 785        ):
 786            setattr(
 787                self,
 788                attr_name,
 789                [float("nan")]
 790                + [
 791                    self._cycles_to_seconds(x)
 792                    for x in self._read_serial(4, "uint32")
 793                ],
 794            )
 795
 796        for attr_name in (
 797                "phase1_voltage",
 798                "phase2_voltage",
 799                "resting_voltage",
 800        ):
 801            setattr(
 802                self,
 803                attr_name,
 804                [float("nan")]
 805                + [
 806                    self._bits_to_volts(x)
 807                    for x in self._read_serial(4, "uint16")
 808                ],
 809            )
 810
 811        for attr_name in (
 812                "is_biphasic",
 813                "custom_train_id",
 814                "custom_train_target",
 815                "custom_train_loop",
 816                "link_trigger_channel1",
 817                "link_trigger_channel2",
 818        ):
 819            setattr(
 820                self,
 821                attr_name,
 822                [float("nan")] + self._read_serial(4, "uint8"),
 823            )
 824        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")
 825
 826    def send_custom_pulse_train(
 827        self,
 828        custom_train_id,
 829        pulse_times,
 830        pulse_voltages,
 831    ):
 832        """Load a custom pulse train onto the device.
 833
 834        A custom pulse train is an arbitrary list of pulse onset times
 835        and voltages, replacing the parametric pulse voltage and onset timing.
 836        Set `PulsePalDevice.custom_train_id` on an output channel to play
 837        the train there.
 838
 839        ```python
 840        P.send_custom_pulse_train(
 841            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
 842        )
 843        P.set_output_param("custom_train_id", 1, 2)
 844        ```
 845
 846        Args:
 847            custom_train_id: Custom train to load, 1-2. See
 848                `DeviceInfo.n_custom_pulse_trains`.
 849            pulse_times: Pulse onset times, in seconds, relative to the
 850                start of the train. Accepts a list, tuple or NumPy
 851                array.
 852            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
 853                Must be the same length as `pulse_times`.
 854
 855        Raises:
 856            PulsePalError: If `pulse_times` and `pulse_voltages` differ
 857                in length, or the device does not acknowledge the
 858                command.
 859        """
 860        pulse_times = self._as_list(pulse_times)
 861        pulse_voltages = self._as_list(pulse_voltages)
 862        n_pulses = len(pulse_times)
 863        if n_pulses != len(pulse_voltages):
 864            raise PulsePalError(
 865                "pulse_times and pulse_voltages must be the same length."
 866            )
 867
 868        pulse_times_cycles = [
 869            self._seconds_to_cycles(pulse_time)
 870            for pulse_time in pulse_times
 871        ]
 872        pulse_voltage_bits = [
 873            self._volts_to_bits(voltage)
 874            for voltage in pulse_voltages
 875        ]
 876
 877        op_code = int(custom_train_id) + 74
 878        self._write_serial(
 879            (self._OP_MENU_BYTE, op_code),
 880            "uint8",
 881            n_pulses,
 882            "uint32",
 883            pulse_times_cycles,
 884            "uint32",
 885            pulse_voltage_bits,
 886            "uint16",
 887        )
 888        self._read_ack("send_custom_pulse_train()")
 889
 890    def send_custom_waveform(
 891        self,
 892        custom_train_id,
 893        pulse_width,
 894        pulse_voltages,
 895    ):
 896        """Load an arbitrary waveform onto the device.
 897
 898        A convenience shorthand for
 899        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
 900        confluent pulses, so that `pulse_voltages` is played as a
 901        waveform sampled every `pulse_width` seconds.
 902
 903        Set the channel's `PulsePalDevice.phase1_duration` to
 904        `pulse_width` as well, so that each sample is held for the
 905        sampling period.
 906
 907        ```python
 908        import math
 909
 910        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
 911        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
 912        P.set_output_param("custom_train_id", 2, 1)
 913        P.set_output_param("phase1_duration", 2, 0.001)
 914        ```
 915
 916        Args:
 917            custom_train_id: Custom train to load, 1-2. See
 918                `DeviceInfo.n_custom_pulse_trains`.
 919            pulse_width: Sampling period, in seconds. Each voltage is
 920                held for this long.
 921            pulse_voltages: Waveform samples, in volts [-10, 10].
 922                Accepts a list, tuple or NumPy array.
 923
 924        Raises:
 925            PulsePalError: If the device does not acknowledge the
 926                command.
 927        """
 928        pulse_voltages = self._as_list(pulse_voltages)
 929        n_pulses = len(pulse_voltages)
 930        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
 931        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
 932        pulse_voltage_bits = [
 933            self._volts_to_bits(voltage)
 934            for voltage in pulse_voltages
 935        ]
 936
 937        op_code = int(custom_train_id) + 74
 938        self._write_serial(
 939            (self._OP_MENU_BYTE, op_code),
 940            "uint8",
 941            n_pulses,
 942            "uint32",
 943            pulse_times,
 944            "uint32",
 945            pulse_voltage_bits,
 946            "uint16",
 947        )
 948        self._read_ack("send_custom_waveform()")
 949
 950    def trigger(
 951        self,
 952        channel1=None,
 953        channel2=None,
 954        channel3=None,
 955        channel4=None,
 956    ):
 957        """Trigger output channels in software.
 958
 959        Triggered channels start their pulse trains together. Three
 960        calling conventions are accepted:
 961
 962        ```python
 963        P.trigger(1, 0, 1, 0)   # one flag per channel
 964        P.trigger(3)            # a single channel number
 965        P.trigger([1, 4])       # several channel numbers
 966        ```
 967
 968        Channel numbers outside 1-4 are ignored.
 969
 970        Args:
 971            channel1: `1` to trigger channel 1, otherwise `0`; or, when
 972                it is the only argument given, a single channel number
 973                or a list of channel numbers to trigger.
 974            channel2: `1` to trigger channel 2, otherwise `0`.
 975            channel3: `1` to trigger channel 3, otherwise `0`.
 976            channel4: `1` to trigger channel 4, otherwise `0`.
 977        """
 978        trigger_byte = 0
 979
 980        # Options 2 & 3: Only one argument was provided
 981        if channel2 is None and channel3 is None and channel4 is None:
 982            # Option 2: Single integer
 983            if isinstance(channel1, int):
 984                channels_to_trigger = [channel1]
 985            # Option 3: List/Tuple of integers
 986            elif isinstance(channel1, (list, tuple, set)):
 987                channels_to_trigger = channel1
 988            else:
 989                channels_to_trigger = []
 990
 991            # Use bitwise shifts to calculate the trigger byte
 992            # (ch1=bit0, ch2=bit1, etc.)
 993            for ch in channels_to_trigger:
 994                if 1 <= ch <= 4:
 995                    trigger_byte |= (1 << (ch - 1))
 996
 997        # Option 1: Original input scheme (logicals for each channel)
 998        else:
 999            # Fallback to 0 if an argument was omitted via kwargs
1000            c1 = channel1 if channel1 is not None else 0
1001            c2 = channel2 if channel2 is not None else 0
1002            c3 = channel3 if channel3 is not None else 0
1003            c4 = channel4 if channel4 is not None else 0
1004
1005            trigger_byte = (
1006                (1 * c1)
1007                + (2 * c2)
1008                + (4 * c3)
1009                + (8 * c4)
1010            )
1011
1012        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
1013
1014    def sd_settings(self, settings_file_name, op):
1015        """Save, load, or delete a settings file on the microSD card.
1016
1017        A settings file holds a complete Pulse Pal program, so that it
1018        can be recalled later from software or from the device's front
1019        panel. Loading a file also refreshes the local copy of the
1020        parameters, via `PulsePalDevice.sync_from_device`.
1021
1022        ```python
1023        P.sd_settings("MyProtocol.pps", "save")
1024        ```
1025
1026        Args:
1027            settings_file_name: Settings file name, at most 15 ASCII
1028                characters including the required `.pps` extension.
1029            op: `"save"`, `"load"`, or `"delete"`.
1030
1031        Raises:
1032            PulsePalError: If the file name has no `.pps` extension or
1033                is too long, `op` is not one of the three operations, or
1034                the device does not acknowledge the command.
1035        """
1036        if ".pps" not in settings_file_name:
1037            raise PulsePalError(
1038                "Error: The file name must have a valid .pps extension."
1039            )
1040        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1041        try:
1042            op_byte = op_byte_by_name[str(op).lower()]
1043        except KeyError as exc:
1044            raise PulsePalError(
1045                "File op must be: 'save', 'load' or 'delete'."
1046            ) from exc
1047
1048        filename_bytes = settings_file_name.encode("ascii")
1049        if len(filename_bytes) > 15:
1050            raise PulsePalError("settings_file_name is too long.")
1051        self._write_serial(
1052            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1053            "uint8",
1054            list(filename_bytes),
1055            "uint8",
1056        )
1057        if self.info.firmware_version > 21:
1058            self._read_ack("sd_settings()")
1059        if op_byte == 2:
1060            time.sleep(0.1)
1061            self.sync_from_device()
1062
1063    def stop(self, channels=None):
1064        """Stop pulse trains currently playing on the device.
1065
1066        Every output channel returns to its
1067        `PulsePalDevice.resting_voltage`.
1068
1069        Args:
1070            channels (list or tuple, optional): A list of channels to stop, e.g.,
1071                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4. Default is None,
1072                which stops all channels. (Requires firmware v22+)
1073        """
1074        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1075
1076        if channels is not None:
1077            if self.info.firmware_version < 22:
1078                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1079
1080            # If a single integer is provided, wrap it in a list
1081            if isinstance(channels, int):
1082                channels = [channels]
1083
1084            bit_code = 0
1085            for ch in channels:
1086                if ch not in (1, 2, 3, 4):
1087                    raise ValueError("All channels must be valid Pulse Pal output channel indexes: 1, 2, 3 or 4")
1088
1089                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1090                bit_code |= 1 << (ch - 1)
1091
1092        # Send
1093        if self.info.firmware_version < 22:
1094            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1095        else:
1096            self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")
1097
1098    def format_microsd(self, timeout=30):
1099        """Format the device's microSD card.
1100
1101        Erases every settings file stored on the device and resets its
1102        parameters to the defaults. The user is prompted at the console
1103        to confirm before anything is erased.
1104
1105        Requires Pulse Pal hardware v3 or newer.
1106
1107        Args:
1108            timeout: Seconds to wait for the device to report that
1109                formatting has finished.
1110
1111        Returns:
1112            `None` once the card has been formatted, or an empty string
1113            if the user declines the confirmation prompt.
1114
1115        Raises:
1116            PulsePalError: If the connected hardware is older than v3.
1117        """
1118        if self.info.hardware_version < 3:
1119            raise PulsePalError(
1120                "format_microsd() requires hardware v3 or newer."
1121            )
1122
1123        print("*** Pulse Pal microSD Formatter ***")
1124        print("This will format Pulse Pal's microSD card,")
1125        print("erase all settings files on the device")
1126        print("and reset all parameters to defaults.")
1127
1128        reply = input("Do you want to continue (y/n)")
1129
1130        if reply.strip().lower() != "y":
1131            print("Choice confirmed - microSD Card NOT formatted.")
1132            return ""
1133
1134        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1135
1136        start = time.time()
1137        message = bytearray()
1138
1139        while time.time() - start < timeout:
1140            n_waiting = self.bytes_available()
1141            if n_waiting:
1142                message.extend(self.port.read(n_waiting))
1143                if ord("!") in message:
1144                    break
1145            time.sleep(0.01)
1146        raw_message = bytes(message)
1147        flag_index = raw_message.find(b"!")
1148        if flag_index >= 0:
1149            displayed_message = raw_message[:flag_index]
1150        else:
1151            displayed_message = raw_message
1152
1153        text = displayed_message.decode("ascii", errors="replace").rstrip()
1154        if text:
1155            print(text)
1156
1157        self.set_default_params()
1158        return None
1159
1160    def gui(self, block=None, theme=None):
1161        """Open the Pulse Pal parameter GUI, or focus an open one.
1162
1163        The GUI edits its own copy of the parameters, and loads them to
1164        the device when its 'Load to Device' button is clicked. The
1165        window closes automatically when the device is closed or
1166        deleted.
1167
1168        Calling this while the GUI is already open focuses the existing
1169        window rather than opening a second one.
1170
1171        Args:
1172            block: If `True`, the call returns when the GUI is closed. If
1173                `False`, the call returns immediately, and the host
1174                application must run the Tk event loop. If `None`, the
1175                GUI blocks only when the host does not already provide a
1176                Tk event loop, e.g. when launched from a script.
1177            theme: `"light"` or `"dark"` to select the color theme, or
1178                `None` to match the desktop theme. Passing a theme to an
1179                already-open GUI recolors it in place.
1180
1181        Returns:
1182            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1183
1184        Raises:
1185            ValueError: If the theme name is not recognized.
1186        """
1187        gui = getattr(self, "_gui", None)
1188        if gui is not None and not gui.is_closed:
1189            if theme is not None:
1190                gui.set_theme(theme)
1191            gui.focus()
1192            return gui
1193
1194        try:
1195            from .PulsePalGUI import PulsePalGUI
1196        except ImportError:
1197            from PulsePalGUI import PulsePalGUI
1198
1199        gui = PulsePalGUI(self, theme=theme)
1200        self._gui = gui
1201        gui.start(block=block)
1202        return gui
1203
1204    def close(self, send_disconnect=True):
1205        """Close the connection to the device, and the GUI if open.
1206
1207        Safe to call more than once; later calls do nothing. Called
1208        automatically when leaving a `with` block and when the object is
1209        garbage collected.
1210
1211        Args:
1212            send_disconnect: If `True`, tell the device that the client
1213                is disconnecting before closing the port. Set to `False`
1214                when the device is in an unknown state, such as after a
1215                failed handshake.
1216        """
1217        gui = getattr(self, "_gui", None)
1218        self._gui = None
1219        if gui is not None:
1220            try:
1221                gui.close()
1222            except Exception:
1223                # Cleanup must not raise; Tk may already be torn down
1224                pass
1225
1226        if getattr(self, "_closed", True):
1227            return
1228
1229        try:
1230            if send_disconnect and self.port and self.port.is_open:
1231                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1232        finally:
1233            if self.port and self.port.is_open:
1234                self.port.close()
1235            self._closed = True
1236
1237    def bytes_available(self):
1238        """Return the number of bytes waiting in the serial read buffer.
1239
1240        Returns:
1241            Count of bytes that can be read without blocking.
1242        """
1243        return self.port.in_waiting
1244
1245    def _get_output_param_code(self, param_name):
1246        """Resolve an output parameter name or code to its code."""
1247        if isinstance(param_name, str):
1248            try:
1249                return self.info.output_parameter_names.index(param_name) + 1
1250            except ValueError as exc:
1251                raise PulsePalError(
1252                    f"Unknown output parameter: {param_name}."
1253                ) from exc
1254        return int(param_name)
1255
1256    def _get_trigger_param_code(self, param_name):
1257        """Resolve a trigger parameter name or code to its code."""
1258        if isinstance(param_name, str):
1259            try:
1260                index = self.info.trigger_parameter_names.index(param_name)
1261                return index + 128
1262            except ValueError as exc:
1263                raise PulsePalError(
1264                    f"Unknown trigger parameter: {param_name}."
1265                ) from exc
1266        return int(param_name)
1267
1268    def _write_serial(self, *args):
1269        """Write one or more data/type pairs to the serial port."""
1270        if len(args) % 2 != 0:
1271            raise PulsePalError(
1272                "Serial writes require data/type argument pairs."
1273            )
1274
1275        payload = bytearray()
1276        for i in range(0, len(args), 2):
1277            payload.extend(self._pack_values(args[i], args[i + 1]))
1278
1279        bytes_written = self.port.write(bytes(payload))
1280        if bytes_written != len(payload):
1281            raise PulsePalError(
1282                f"Error: wrote {bytes_written} byte(s), expected to write "
1283                f"{len(payload)} byte(s)."
1284            )
1285
1286    def _read_serial(self, n_values, datatype):
1287        """Read values from the serial port and unpack them with struct."""
1288        datatype = self._normalize_datatype(datatype)
1289        fmt = self._STRUCT_FORMATS[datatype]
1290        n_values = int(n_values)
1291        n_bytes = n_values * struct.calcsize(fmt)
1292        message_bytes = self.port.read(n_bytes)
1293        if len(message_bytes) < n_bytes:
1294            raise PulsePalError(
1295                f"Error: serial port timed out. "
1296                f"{len(message_bytes)} byte(s) read. "
1297                f"Expected {n_bytes} byte(s)."
1298            )
1299
1300        values = struct.unpack(
1301            f"{self._ENDIANNESS}{n_values}{fmt}",
1302            message_bytes,
1303        )
1304        if n_values == 1:
1305            return values[0]
1306        return list(values)
1307
1308    def _read_ack(self, context):
1309        """Read a one-byte acknowledgement from the device."""
1310        try:
1311            self._read_serial(1, "uint8")
1312        except PulsePalError as exc:
1313            raise PulsePalError(
1314                "Error: Pulse Pal did not return an acknowledgement byte "
1315                f"after a call to {context}."
1316            ) from exc
1317
1318    def _pack_values(self, values, datatype):
1319        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1320        datatype = self._normalize_datatype(datatype)
1321        fmt = self._STRUCT_FORMATS[datatype]
1322        values_list = self._as_list(values)
1323
1324        if datatype == "char":
1325            values_list = self._normalize_char_values(values_list)
1326        elif datatype in self._TYPE_RANGES:
1327            values_list = self._normalize_int_values(values_list, datatype)
1328        else:
1329            values_list = [float(value) for value in values_list]
1330
1331        return struct.pack(
1332            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1333            *values_list,
1334        )
1335
1336    def _normalize_datatype(self, datatype):
1337        """Return the datatype name, rejecting unsupported types."""
1338        datatype = str(datatype)
1339        if datatype not in self._STRUCT_FORMATS:
1340            raise PulsePalError(
1341                f"Error: {datatype} is not a data type supported by "
1342                "PulsePalObject."
1343            )
1344        return datatype
1345
1346    def _normalize_char_values(self, values):
1347        """Coerce str, int and bytes values to single ASCII bytes."""
1348        normalized = []
1349        for value in values:
1350            if isinstance(value, str):
1351                value = value.encode("ascii")
1352            if isinstance(value, int):
1353                value = bytes((value,))
1354            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1355                raise PulsePalError(
1356                    "char values must be one-byte bytes, chars, or "
1357                    "integers."
1358                )
1359            normalized.append(bytes(value))
1360        return normalized
1361
1362    def _normalize_int_values(self, values, datatype):
1363        """Coerce values to ints, rejecting any out of range."""
1364        min_value, max_value = self._TYPE_RANGES[datatype]
1365        normalized = []
1366        for value in values:
1367            value = int(value)
1368            if not min_value <= value <= max_value:
1369                raise PulsePalError(
1370                    f"Value {value} is out of range for {datatype} "
1371                    f"({min_value} to {max_value})."
1372                )
1373            normalized.append(value)
1374        return normalized
1375
1376    def _as_list(self, values):
1377        """Return values as a flat list, wrapping scalars in one."""
1378        if isinstance(values, np.ndarray):
1379            return values.ravel().tolist()
1380        if isinstance(values, (bytes, bytearray, str)):
1381            return [values]
1382        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1383            return [values]
1384        try:
1385            return list(values)
1386        except TypeError:
1387            return [values]
1388
1389    def _set_output_param_value(self, param_code, channel, original_value):
1390        """Store a programmed value in its local parameter array."""
1391        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1392        if attr_name is None:
1393            return
1394        values = getattr(self, attr_name)
1395        values[channel] = original_value
1396
1397    def _to_decimal(self, value):
1398        """Convert a value to a Decimal with PulsePal precision."""
1399        return Decimal(value).quantize(Decimal("1.0000"))
1400
1401    def _volts_to_bits(self, value):
1402        """Convert -10 V to +10 V to the corresponding DAC bit value."""
1403        normalized = (float(value) + 10) / 20
1404        bit_max = int(self._dac_bit_max)
1405        return int(min(max(round(normalized * bit_max), 0), bit_max))
1406
1407    def _bits_to_volts(self, value):
1408        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1409        bit_max = int(self._dac_bit_max)
1410        raw_volts = (float(value) / bit_max * 20) - 10
1411
1412        # Calculate the voltage of 1 bit
1413        lsb_volts = 20.0 / bit_max
1414
1415        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1416        clean_volts = round(raw_volts, 3)
1417
1418        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1419        if abs(raw_volts - clean_volts) <= lsb_volts:
1420            return clean_volts
1421
1422        # Otherwise, return the standard 4-decimal reading
1423        return round(raw_volts, 4)
1424
1425    def _seconds_to_cycles(self, value):
1426        """Convert seconds to the corresponding refresh-cycle count."""
1427        return int(round(float(value) * float(self.info.cycle_frequency)))
1428
1429    def _cycles_to_seconds(self, value):
1430        """Convert hardware timer cycle counts to seconds."""
1431        return float(value) / float(self.info.cycle_frequency)
1432
1433    def _require_firmware(self, minimum_version, context):
1434        """Raise unless the device firmware is new enough for an op."""
1435        if (
1436            self.info.firmware_version is None
1437            or self.info.firmware_version < minimum_version
1438        ):
1439            raise PulsePalError(
1440                f"{context} requires firmware v{minimum_version} or newer. "
1441                f"Detected firmware is v{self.info.firmware_version}."
1442            )
1443
1444    def _sync_all_params(self):
1445        """Send all parameters using the packed sync op (firmware v22+).
1446
1447        Values are grouped by width so that the whole program travels as
1448        one uint32 block, one uint16 block and one uint8 block.
1449        """
1450        time_values = []
1451        for attr_name in (
1452            "phase1_duration",
1453            "inter_phase_interval",
1454            "phase2_duration",
1455            "inter_pulse_interval",
1456            "burst_duration",
1457            "inter_burst_interval",
1458            "pulse_train_duration",
1459            "pulse_train_delay",
1460        ):
1461            time_values.extend(
1462                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1463                for channel in range(1, 5)
1464            )
1465
1466        voltage_values = []
1467        for attr_name in (
1468            "phase1_voltage",
1469            "phase2_voltage",
1470            "resting_voltage",
1471        ):
1472            voltage_values.extend(
1473                self._volts_to_bits(getattr(self, attr_name)[channel])
1474                for channel in range(1, 5)
1475            )
1476
1477        single_byte_values = []
1478        for attr_name in (
1479            "is_biphasic",
1480            "custom_train_id",
1481            "custom_train_target",
1482            "custom_train_loop",
1483            "playback_mode",
1484        ):
1485            single_byte_values.extend(
1486                int(getattr(self, attr_name)[channel])
1487                for channel in range(1, 5)
1488            )
1489        single_byte_values.extend(
1490            int(self.link_trigger_channel1[channel])
1491            for channel in range(1, 5)
1492        )
1493        single_byte_values.extend(
1494            int(self.link_trigger_channel2[channel])
1495            for channel in range(1, 5)
1496        )
1497        single_byte_values.extend(
1498            int(value) for value in self.trigger_mode[1:3]
1499        )
1500
1501        self._write_serial(
1502            (self._OP_MENU_BYTE, 92),
1503            "uint8",
1504            time_values,
1505            "uint32",
1506            voltage_values,
1507            "uint16",
1508            single_byte_values,
1509            "uint8",
1510        )
1511
1512    def _sync_all_params_legacy(self):
1513        """Send all parameters using the legacy sync op (firmware v21).
1514
1515        Equivalent to `_sync_all_params`, but lays the program out
1516        channel by channel as the older firmware expects.
1517        """
1518        program_values_16 = []
1519        program_values_32 = []
1520        program_values_8 = [0] * 16
1521
1522        for channel in range(1, 5):
1523            program_values_32.extend(
1524                [
1525                    self._seconds_to_cycles(
1526                        self.phase1_duration[channel]),
1527                    self._seconds_to_cycles(
1528                        self.inter_phase_interval[channel]),
1529                    self._seconds_to_cycles(
1530                        self.phase2_duration[channel]),
1531                    self._seconds_to_cycles(
1532                        self.inter_pulse_interval[channel]),
1533                    self._seconds_to_cycles(
1534                        self.burst_duration[channel]),
1535                    self._seconds_to_cycles(
1536                        self.inter_burst_interval[channel]),
1537                    self._seconds_to_cycles(
1538                        self.pulse_train_duration[channel]),
1539                    self._seconds_to_cycles(
1540                        self.pulse_train_delay[channel]),
1541                ]
1542            )
1543
1544        for channel in range(1, 5):
1545            program_values_16.extend(
1546                [
1547                    self._volts_to_bits(self.phase1_voltage[channel]),
1548                    self._volts_to_bits(self.phase2_voltage[channel]),
1549                    self._volts_to_bits(self.resting_voltage[channel]),
1550                ]
1551            )
1552
1553        position = 0
1554        for channel in range(1, 5):
1555            program_values_8[position] = self.is_biphasic[channel]
1556            position += 1
1557            program_values_8[position] = self.custom_train_id[channel]
1558            position += 1
1559            program_values_8[position] = self.custom_train_target[channel]
1560            position += 1
1561            program_values_8[position] = self.custom_train_loop[channel]
1562            position += 1
1563
1564        program_values_tl = [0] * 8
1565        position = 0
1566        for channel in range(1, 5):
1567            program_values_tl[position] = self.link_trigger_channel1[channel]
1568            position += 1
1569        for channel in range(1, 5):
1570            program_values_tl[position] = self.link_trigger_channel2[channel]
1571            position += 1
1572
1573        self._write_serial(
1574            (self._OP_MENU_BYTE, 73),
1575            "uint8",
1576            program_values_32,
1577            "uint32",
1578            program_values_16,
1579            "uint16",
1580            program_values_8,
1581            "uint8",
1582            program_values_tl,
1583            "uint8",
1584            self.trigger_mode[1:3],
1585            "uint8",
1586        )
1587
1588    def __enter__(self):
1589        """Enter a `with` block, returning the connected device."""
1590        return self
1591
1592    def __exit__(self, exc_type, exc_value, traceback):
1593        """Disconnect and close the port when leaving a `with` block.
1594
1595        Returns:
1596            `False`, so any exception raised in the block propagates.
1597        """
1598        try:
1599            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1600        except Exception:
1601            pass
1602        self.close()
1603        return False
1604
1605    def __del__(self):
1606        """Disconnect and close the port when the object is collected."""
1607        try:
1608            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1609        except Exception:
1610            pass
1611
1612        try:
1613            self.close()
1614        except Exception:
1615            # Destructors should not raise; the serial object may already be
1616            # gone during interpreter shutdown.
1617            pass
class PulsePalDevice:
 155class PulsePalDevice:
 156    """A class to control a Pulse Pal device on a USB serial port.
 157
 158    Creating an instance opens the serial port, exchanges a handshake
 159    with the device, verifies that its firmware is supported, reads the
 160    device properties into `PulsePalDevice.info`, and programs the
 161    device with the default parameters.
 162
 163    ```python
 164    from PulsePal import PulsePalDevice
 165
 166    P = PulsePalDevice("COM3")
 167    P.set_output_param("phase1_voltage", 1, 5)
 168    P.trigger(1)
 169    P.close()
 170    ```
 171
 172    Here, replace "COM3" with Pulse Pal's USB serial port name.
 173    To view a list of available ports, use
 174    PulsePalDevice.serialportlist()
 175    Pulse Pal's port may not be visible if it is connected to another
 176    instance of PulsePalDevice or an external application. Use
 177    PulsePalDevice.serialportlist('all') to view all ports.
 178    If you see multiple available ports, disconnect Pulse Pal's USB plug
 179    and re-run serialportlist(). Notice which port disappears from the list.
 180
 181    PulsePalDevice is also a context manager, which closes the connection on
 182    exit even if an error is raised:
 183
 184    ```python
 185    with PulsePalDevice("COM3") as P:
 186        P.trigger(1)
 187    ```
 188
 189    The attributes below named after Pulse Pal parameters are the local
 190    copy of the device's program. Each is a five element list indexed by
 191    channel number, with index 0 unused. This way channels are addressed
 192    by the index on the device, e.g. trigger channels 1-2 and output
 193    channels 1-4. Assigning parameters does not update the device until
 194    `PulsePalDevice.sync_to_device` is called;
 195    `PulsePalDevice.set_output_param` programs one parameter right away.
 196    """
 197
 198    port: "serial.Serial"
 199    """The open `serial.Serial` port connected to the device."""
 200
 201    info: DeviceInfo
 202    """Properties of the connected device. See `DeviceInfo`."""
 203
 204    is_biphasic: list
 205    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 206
 207    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 208    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 209    phase 2.
 210    """
 211
 212    phase1_voltage: list
 213    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 214
 215    phase2_voltage: list
 216    """Voltage of the second phase of each pulse, in volts [-10, 10].
 217
 218    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 219    """
 220
 221    resting_voltage: list
 222    """Voltage held between pulses, in volts [-10, 10]."""
 223
 224    phase1_duration: list
 225    """Duration of the first phase of each pulse, in seconds."""
 226
 227    inter_phase_interval: list
 228    """Interval between the two phases of a biphasic pulse, in seconds.
 229
 230    The channel rests at `PulsePalDevice.resting_voltage` during the
 231    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 232    """
 233
 234    phase2_duration: list
 235    """Duration of the second phase of each pulse, in seconds.
 236
 237    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 238    """
 239
 240    inter_pulse_interval: list
 241    """Interval from the end of one pulse to the onset of the next, in
 242    seconds."""
 243
 244    burst_duration: list
 245    """Duration of each burst of pulses, in seconds.
 246
 247    Set to `0` to disable bursts, so that pulses continue for the whole
 248    pulse train.
 249    """
 250
 251    inter_burst_interval: list
 252    """Interval between bursts of pulses, in seconds.
 253
 254    The channel rests at `PulsePalDevice.resting_voltage` between
 255    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 256    """
 257
 258    pulse_train_duration: list
 259    """Total duration of the pulse train, in seconds."""
 260
 261    pulse_train_delay: list
 262    """Delay from the trigger to the onset of the pulse train, in
 263    seconds."""
 264
 265    link_trigger_channel1: list
 266    """Whether each output channel is linked to trigger channel 1.
 267
 268    `1` links the output channel to trigger channel 1, `0` unlinks it.
 269    """
 270
 271    link_trigger_channel2: list
 272    """Whether each output channel is linked to trigger channel 2.
 273
 274    `1` links the output channel to trigger channel 2, `0` unlinks it.
 275    """
 276
 277    custom_train_id: list
 278    """Custom pulse train played by each output channel.
 279
 280    `0` plays the parametrically defined train. `1` or higher plays the
 281    matching custom train, previously loaded with
 282    `PulsePalDevice.send_custom_pulse_train` or
 283    `PulsePalDevice.send_custom_waveform`.
 284    """
 285
 286    custom_train_target: list
 287    """What the timestamps of a custom train mark.
 288
 289    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 290    is the onset of a burst of pulses.
 291    """
 292
 293    custom_train_loop: list
 294    """Whether a custom train repeats.
 295
 296    `1` loops the custom train until
 297    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 298    once.
 299    """
 300
 301    playback_mode: list
 302    """Continuous playback mode of parametric pulse trains after being triggered
 303
 304    - `0` plays the pulse train once until pulse_train_duration seconds
 305    - `1` plays the pulse train indefinitely, ignoring pulse_train_duration
 306    
 307    """
 308
 309    trigger_mode: list
 310    """Response of each trigger channel to an incoming TTL pulse.
 311
 312    Three element list indexed by trigger channel, with index 0 unused.
 313    Elements 1 and 2 control the respective channels on the device.
 314    Their values can be:
 315
 316    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 317      during the train are ignored.
 318    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 319    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 320    """
 321
 322    _CURRENT_FIRMWARE_VERSION = 22
 323
 324    _OP_MENU_BYTE = 213
 325    _HANDSHAKE_OPCODE = 72
 326    _HANDSHAKE_RESPONSE = 75
 327    _DAC_BITMAX = 65535
 328    _OLDEST_FIRMWARE_SUPPORTED = 21
 329
 330    _OUTPUT_PARAMETER_NAMES = (
 331        "is_biphasic",
 332        "phase1_voltage",
 333        "phase2_voltage",
 334        "phase1_duration",
 335        "inter_phase_interval",
 336        "phase2_duration",
 337        "inter_pulse_interval",
 338        "burst_duration",
 339        "inter_burst_interval",
 340        "pulse_train_duration",
 341        "pulse_train_delay",
 342        "link_trigger_channel1",
 343        "link_trigger_channel2",
 344        "custom_train_id",
 345        "custom_train_target",
 346        "custom_train_loop",
 347        "playback_mode",
 348        "resting_voltage",
 349    )
 350    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 351
 352    _OUTPUT_PARAMETER_ATTRS = {
 353        1: "is_biphasic",
 354        2: "phase1_voltage",
 355        3: "phase2_voltage",
 356        4: "phase1_duration",
 357        5: "inter_phase_interval",
 358        6: "phase2_duration",
 359        7: "inter_pulse_interval",
 360        8: "burst_duration",
 361        9: "inter_burst_interval",
 362        10: "pulse_train_duration",
 363        11: "pulse_train_delay",
 364        12: "link_trigger_channel1",
 365        13: "link_trigger_channel2",
 366        14: "custom_train_id",
 367        15: "custom_train_target",
 368        16: "custom_train_loop",
 369        17: "resting_voltage",
 370        18: "playback_mode",
 371    }
 372    _ENDIANNESS = "<"
 373    _STRUCT_FORMATS = {
 374        "uint8": "B",
 375        "int8": "b",
 376        "char": "c",
 377        "uint16": "H",
 378        "int16": "h",
 379        "uint32": "I",
 380        "int32": "i",
 381        "single": "f",
 382        "double": "d",
 383    }
 384    _TYPE_RANGES = {
 385        "uint8": (0, 2**8 - 1),
 386        "int8": (-(2**7), 2**7 - 1),
 387        "uint16": (0, 2**16 - 1),
 388        "int16": (-(2**15), 2**15 - 1),
 389        "uint32": (0, 2**32 - 1),
 390        "int32": (-(2**31), 2**31 - 1),
 391    }
 392
 393    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 394        """Open a connection to a Pulse Pal device.
 395
 396        Opens the serial port, exchanges the handshake, verifies the
 397        firmware version, reads the device properties into
 398        `PulsePalDevice.info`, and programs the device with the default
 399        parameters.
 400
 401        Args:
 402            port_name: USB serial port for the Pulse Pal device, such as
 403                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 404            baud_rate: Serial baud rate.
 405            timeout: Serial read timeout, in seconds.
 406
 407        Raises:
 408            PulsePalError: If the device does not return the expected
 409                handshake, or its firmware is older than v21, or its
 410                firmware is newer than this module supports.
 411            serial.SerialException: If the serial port cannot be opened.
 412        """
 413        self.info = DeviceInfo()
 414        self._gui = None
 415        self.port = serial.Serial(
 416            port_name,
 417            baud_rate,
 418            timeout=timeout,
 419            rtscts=True,
 420        )
 421        self._closed = False
 422        self._dac_bit_max = self._to_decimal(0)
 423        self.info.firmware_version = None
 424        self.info.hardware_version = None
 425        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 426        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 427
 428        self._write_serial(
 429            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 430            "uint8",
 431        )
 432        handshake = self._read_serial(1, "uint8")
 433        if handshake != self._HANDSHAKE_RESPONSE:
 434            self.close(send_disconnect=False)
 435            raise PulsePalError(
 436                "Error: incorrect handshake returned. Expected "
 437                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 438            )
 439
 440        firmware_version = self._read_serial(1, "uint32")
 441        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 442            raise PulsePalError(
 443                "Error: Old firmware detected, v"
 444                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 445                "newer is required."
 446            )
 447        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 448            raise PulsePalError(
 449                "Error: Future firmware detected, v"
 450                f"{firmware_version}. Please update PulsePal.py or downgrade "
 451                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 452            )
 453        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 454            print(
 455                "Old firmware detected, v"
 456                f"{firmware_version}. This firmware is supported. Update to v"
 457                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 458            )
 459        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 460        self.info.firmware_version = firmware_version
 461
 462        if self.info.firmware_version > 21:
 463            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 464            self.info.hardware_version = self._read_serial(1, "uint8")
 465            self.info.cycle_period_us = self._read_serial(1, "uint32")
 466            self.info.cycle_frequency = 1 / (
 467                self.info.cycle_period_us / 1000000
 468            )
 469            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 470            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 471        else:
 472            self.info.hardware_version = 2
 473            self.info.cycle_period_us = 50
 474            self.info.cycle_frequency = 20000
 475            self.info.n_custom_pulse_trains = 2
 476            self.info.max_custom_pulses = 5000
 477
 478        # Client name op + "PYTHON" in ASCII.
 479        self._write_serial(
 480            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 481            "uint8",
 482        )
 483
 484        self.set_default_params()
 485        self.sync_to_device()
 486
 487    @staticmethod
 488    def serialportlist(ports_to_list="available"):
 489        """Return the names of the USB serial ports on this computer.
 490
 491        Called on the class, without connecting to a device, to find the
 492        port name to pass to `PulsePalDevice`:
 493
 494        ```python
 495        from PulsePal import PulsePalDevice
 496
 497        ports = PulsePalDevice.serialportlist()
 498        P = PulsePalDevice(ports[0])
 499        ```
 500
 501        Args:
 502            ports_to_list: `available` to list only the ports that are
 503                not already in use, or `all` to list every USB serial
 504                port. Not case sensitive.
 505
 506        Returns:
 507            Sorted list of port names, such as `["COM3", "COM7"]` on
 508            Windows or `["/dev/ttyACM0"]` on Linux.
 509
 510        Raises:
 511            PulsePalError: If `ports_to_list` is not `available` or
 512                `all`.
 513        """
 514        if not isinstance(ports_to_list, str):
 515            raise PulsePalError(
 516                "serialportlist() takes 'available' or 'all'."
 517            )
 518        mode = ports_to_list.lower()
 519        if mode not in ("available", "all"):
 520            raise PulsePalError(
 521                f"Unknown port list type: {ports_to_list}. "
 522                "Use 'available' or 'all'."
 523            )
 524
 525        port_names = []
 526        for port_info in serial.tools.list_ports.comports():
 527            is_usb = port_info.vid is not None or "USB" in (
 528                port_info.hwid or ""
 529            ).upper()
 530            if not is_usb:
 531                continue
 532            if mode == "available" and not PulsePalDevice._port_is_free(
 533                port_info.device
 534            ):
 535                continue
 536            port_names.append(port_info.device)
 537        return sorted(port_names)
 538
 539    @staticmethod
 540    def _port_is_free(port_name):
 541        """Return True if the port is not already open in another program."""
 542        port = serial.Serial()
 543        port.port = port_name
 544        # Leaving the control lines low avoids resetting boards that
 545        # reset on DTR while the port is probed.
 546        port.dtr = False
 547        port.rts = False
 548        try:
 549            port.open()
 550        except (serial.SerialException, OSError):
 551            return False
 552        port.close()
 553        return True
 554
 555    def set_default_params(self):
 556        """Reset the local copy of all parameters to their defaults.
 557
 558        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 559        1 second, on all four output channels, linked to trigger channel
 560        1 in normal trigger mode.
 561
 562        This updates only the local copy. Call
 563        `PulsePalDevice.sync_to_device` to program the device with them.
 564        """
 565        nan = float("nan")
 566        self.is_biphasic = [nan, 0, 0, 0, 0]
 567        self.phase1_voltage = [nan, 5, 5, 5, 5]
 568        self.phase2_voltage = [nan, -5, -5, -5, -5]
 569        self.resting_voltage = [nan, 0, 0, 0, 0]
 570        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 571        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 572        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 573        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 574        self.burst_duration = [nan, 0, 0, 0, 0]
 575        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 576        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 577        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 578        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 579        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 580        self.custom_train_id = [nan, 0, 0, 0, 0]
 581        self.custom_train_target = [nan, 0, 0, 0, 0]
 582        self.custom_train_loop = [nan, 0, 0, 0, 0]
 583        self.playback_mode = [nan, 0, 0, 0, 0]
 584        self.trigger_mode = [nan, 0, 0]
 585
 586    def set_voltage(self, channel, voltage):
 587        """Set an output channel to a fixed voltage.
 588
 589        The channel holds the voltage until it is set again or until a
 590        pulse train is triggered on it.
 591
 592        Args:
 593            channel: Output channel number, 1-4.
 594            voltage: Voltage to set, in volts [-10, 10].
 595
 596        Raises:
 597            PulsePalError: If the device does not acknowledge the
 598                command.
 599        """
 600        voltage_bits = self._volts_to_bits(voltage)
 601        self._write_serial(
 602            (self._OP_MENU_BYTE, 79, channel),
 603            "uint8",
 604            voltage_bits,
 605            "uint16",
 606        )
 607        self._read_ack("set_fixed_voltage()")
 608
 609    def set_calibration(self, channel, voltage_offset):
 610        """Calibrate the zero code of an output channel.
 611
 612        The offset is added to every voltage the channel produces, to
 613        correct for DAC offset error. It is stored in the device's
 614        EEPROM and reloaded on boot, so it only needs to be set once.
 615
 616        Requires Pulse Pal hardware v3 or newer.
 617
 618        Args:
 619            channel: Output channel number, 1-4.
 620            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 621
 622        Raises:
 623            PulsePalError: If the connected hardware is older than v3,
 624                or the device does not acknowledge the command.
 625            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 626                outside [-0.1, 0.1].
 627        """
 628        if self.info.hardware_version < 3:
 629            raise PulsePalError(
 630                "set_calibration() requires hardware v3 or newer."
 631            )
 632        if channel not in (1, 2, 3, 4):
 633            raise ValueError("channel must be 1, 2, 3 or 4")
 634        if voltage_offset < -0.1 or voltage_offset > 0.1:
 635            raise ValueError(
 636                "voltage_offset for zero code calibration must be in range "
 637                "[-0.1, 0.1]"
 638            )
 639        voltage_bits = voltage_offset * (1 / (20 / 65536))
 640        self._write_serial(
 641            (self._OP_MENU_BYTE, 96, channel - 1),
 642            "uint8",
 643            voltage_bits,
 644            "int16",
 645        )
 646        self._read_ack("set_calibration()")
 647
 648    def set_output_param(self, param_name, channel, value):
 649        """Program a single output channel parameter on the device.
 650
 651        The local copy of the parameter is updated to match, so a later
 652        `PulsePalDevice.sync_to_device` will not undo the change.
 653
 654        ```python
 655        P.set_output_param("is_biphasic", 1, 1)
 656        P.set_output_param("phase1_voltage", 1, 10)
 657        P.set_output_param(3, 1, -10)   # same, by param code
 658        ```
 659
 660        Args:
 661            param_name: Parameter name, as listed in
 662                `DeviceInfo.output_parameter_names`, or its integer
 663                parameter code.
 664            channel: Output channel number, 1-4.
 665            value: Value to set. Units are volts for voltage parameters,
 666                seconds for time parameters, and integers for enumerated
 667                parameters. See the attributes of `PulsePalDevice` for
 668                the meaning of each parameter.
 669
 670        Raises:
 671            PulsePalError: If the parameter name is not recognized, the
 672                value does not fit the datatype the device expects, or
 673                the device does not acknowledge the command.
 674        """
 675        original_value = value
 676        param_code = self._get_output_param_code(param_name)
 677
 678        if param_code in (2, 3, 17):
 679            value = self._volts_to_bits(value)
 680            self._write_serial(
 681                (self._OP_MENU_BYTE, 74, param_code, channel),
 682                "uint8",
 683                value,
 684                "uint16",
 685            )
 686        elif 4 <= param_code <= 11:
 687            self._write_serial(
 688                (self._OP_MENU_BYTE, 74, param_code, channel),
 689                "uint8",
 690                self._seconds_to_cycles(value),
 691                "uint32",
 692            )
 693        else:
 694            self._write_serial(
 695                (self._OP_MENU_BYTE, 74, param_code, channel, value),
 696                "uint8",
 697            )
 698
 699        self._read_ack("program_output_channel_param()")
 700        self._set_output_param_value(param_code, channel, original_value)
 701
 702    def set_trigger_param(self, param_name, channel, value):
 703        """Program a single trigger channel parameter on the device.
 704
 705        The local copy of the parameter is updated to match, so a later
 706        `PulsePalDevice.sync_to_device` will not undo the change.
 707
 708        ```python
 709        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 710        ```
 711
 712        Args:
 713            param_name: Parameter name, as listed in
 714                `DeviceInfo.trigger_parameter_names`, or its integer
 715                parameter code.
 716            channel: Trigger channel number, 1-2.
 717            value: Value to set. See `PulsePalDevice.trigger_mode` for
 718                the trigger modes.
 719
 720        Raises:
 721            PulsePalError: If the parameter name is not recognized, the
 722                value does not fit the datatype the device expects, or
 723                the device does not acknowledge the command.
 724        """
 725        original_value = value
 726        param_code = self._get_trigger_param_code(param_name)
 727
 728        self._write_serial(
 729            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 730            "uint8",
 731        )
 732        self._read_ack("program_trigger_channel_param()")
 733
 734        if param_code in (1, 128):
 735            self.trigger_mode[channel] = original_value
 736
 737    def sync_to_device(self):
 738        """Program the device with the local copy of all parameters.
 739
 740        Call this after assigning to the parameter array attributes, to
 741        send every output and trigger parameter to the device in a
 742        single transaction.
 743
 744        ```python
 745        P.phase1_voltage[1:5] = [5] * 4
 746        P.sync_to_device()
 747        ```
 748
 749        Raises:
 750            PulsePalError: If the device does not acknowledge the
 751                command.
 752        """
 753        # _sync_all_params() for firmware v22+ uses the newer packed sync
 754        # opcode (92). _sync_all_params_legacy() uses the less efficient
 755        # legacy packed sync opcode (73).
 756        if self.info.firmware_version > 21:
 757            self._sync_all_params()
 758        else:
 759            self._sync_all_params_legacy()
 760        self._read_ack("sync_to_device()")
 761
 762    def sync_from_device(self):
 763        """Read all parameters from the device into the local copy.
 764
 765        Overwrites every parameter array attribute with the program
 766        currently stored on the device. Useful after the device has been
 767        reprogrammed from its thumb joystick.
 768
 769        Requires firmware v22 or newer.
 770
 771        Raises:
 772            PulsePalError: If the connected firmware is older than v22,
 773                or the device does not return the full parameter set.
 774        """
 775        self._require_firmware(22, "sync_from_device()")
 776        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 777        for attr_name in (
 778                "phase1_duration",
 779                "inter_phase_interval",
 780                "phase2_duration",
 781                "inter_pulse_interval",
 782                "burst_duration",
 783                "inter_burst_interval",
 784                "pulse_train_duration",
 785                "pulse_train_delay",
 786        ):
 787            setattr(
 788                self,
 789                attr_name,
 790                [float("nan")]
 791                + [
 792                    self._cycles_to_seconds(x)
 793                    for x in self._read_serial(4, "uint32")
 794                ],
 795            )
 796
 797        for attr_name in (
 798                "phase1_voltage",
 799                "phase2_voltage",
 800                "resting_voltage",
 801        ):
 802            setattr(
 803                self,
 804                attr_name,
 805                [float("nan")]
 806                + [
 807                    self._bits_to_volts(x)
 808                    for x in self._read_serial(4, "uint16")
 809                ],
 810            )
 811
 812        for attr_name in (
 813                "is_biphasic",
 814                "custom_train_id",
 815                "custom_train_target",
 816                "custom_train_loop",
 817                "link_trigger_channel1",
 818                "link_trigger_channel2",
 819        ):
 820            setattr(
 821                self,
 822                attr_name,
 823                [float("nan")] + self._read_serial(4, "uint8"),
 824            )
 825        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")
 826
 827    def send_custom_pulse_train(
 828        self,
 829        custom_train_id,
 830        pulse_times,
 831        pulse_voltages,
 832    ):
 833        """Load a custom pulse train onto the device.
 834
 835        A custom pulse train is an arbitrary list of pulse onset times
 836        and voltages, replacing the parametric pulse voltage and onset timing.
 837        Set `PulsePalDevice.custom_train_id` on an output channel to play
 838        the train there.
 839
 840        ```python
 841        P.send_custom_pulse_train(
 842            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
 843        )
 844        P.set_output_param("custom_train_id", 1, 2)
 845        ```
 846
 847        Args:
 848            custom_train_id: Custom train to load, 1-2. See
 849                `DeviceInfo.n_custom_pulse_trains`.
 850            pulse_times: Pulse onset times, in seconds, relative to the
 851                start of the train. Accepts a list, tuple or NumPy
 852                array.
 853            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
 854                Must be the same length as `pulse_times`.
 855
 856        Raises:
 857            PulsePalError: If `pulse_times` and `pulse_voltages` differ
 858                in length, or the device does not acknowledge the
 859                command.
 860        """
 861        pulse_times = self._as_list(pulse_times)
 862        pulse_voltages = self._as_list(pulse_voltages)
 863        n_pulses = len(pulse_times)
 864        if n_pulses != len(pulse_voltages):
 865            raise PulsePalError(
 866                "pulse_times and pulse_voltages must be the same length."
 867            )
 868
 869        pulse_times_cycles = [
 870            self._seconds_to_cycles(pulse_time)
 871            for pulse_time in pulse_times
 872        ]
 873        pulse_voltage_bits = [
 874            self._volts_to_bits(voltage)
 875            for voltage in pulse_voltages
 876        ]
 877
 878        op_code = int(custom_train_id) + 74
 879        self._write_serial(
 880            (self._OP_MENU_BYTE, op_code),
 881            "uint8",
 882            n_pulses,
 883            "uint32",
 884            pulse_times_cycles,
 885            "uint32",
 886            pulse_voltage_bits,
 887            "uint16",
 888        )
 889        self._read_ack("send_custom_pulse_train()")
 890
 891    def send_custom_waveform(
 892        self,
 893        custom_train_id,
 894        pulse_width,
 895        pulse_voltages,
 896    ):
 897        """Load an arbitrary waveform onto the device.
 898
 899        A convenience shorthand for
 900        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
 901        confluent pulses, so that `pulse_voltages` is played as a
 902        waveform sampled every `pulse_width` seconds.
 903
 904        Set the channel's `PulsePalDevice.phase1_duration` to
 905        `pulse_width` as well, so that each sample is held for the
 906        sampling period.
 907
 908        ```python
 909        import math
 910
 911        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
 912        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
 913        P.set_output_param("custom_train_id", 2, 1)
 914        P.set_output_param("phase1_duration", 2, 0.001)
 915        ```
 916
 917        Args:
 918            custom_train_id: Custom train to load, 1-2. See
 919                `DeviceInfo.n_custom_pulse_trains`.
 920            pulse_width: Sampling period, in seconds. Each voltage is
 921                held for this long.
 922            pulse_voltages: Waveform samples, in volts [-10, 10].
 923                Accepts a list, tuple or NumPy array.
 924
 925        Raises:
 926            PulsePalError: If the device does not acknowledge the
 927                command.
 928        """
 929        pulse_voltages = self._as_list(pulse_voltages)
 930        n_pulses = len(pulse_voltages)
 931        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
 932        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
 933        pulse_voltage_bits = [
 934            self._volts_to_bits(voltage)
 935            for voltage in pulse_voltages
 936        ]
 937
 938        op_code = int(custom_train_id) + 74
 939        self._write_serial(
 940            (self._OP_MENU_BYTE, op_code),
 941            "uint8",
 942            n_pulses,
 943            "uint32",
 944            pulse_times,
 945            "uint32",
 946            pulse_voltage_bits,
 947            "uint16",
 948        )
 949        self._read_ack("send_custom_waveform()")
 950
 951    def trigger(
 952        self,
 953        channel1=None,
 954        channel2=None,
 955        channel3=None,
 956        channel4=None,
 957    ):
 958        """Trigger output channels in software.
 959
 960        Triggered channels start their pulse trains together. Three
 961        calling conventions are accepted:
 962
 963        ```python
 964        P.trigger(1, 0, 1, 0)   # one flag per channel
 965        P.trigger(3)            # a single channel number
 966        P.trigger([1, 4])       # several channel numbers
 967        ```
 968
 969        Channel numbers outside 1-4 are ignored.
 970
 971        Args:
 972            channel1: `1` to trigger channel 1, otherwise `0`; or, when
 973                it is the only argument given, a single channel number
 974                or a list of channel numbers to trigger.
 975            channel2: `1` to trigger channel 2, otherwise `0`.
 976            channel3: `1` to trigger channel 3, otherwise `0`.
 977            channel4: `1` to trigger channel 4, otherwise `0`.
 978        """
 979        trigger_byte = 0
 980
 981        # Options 2 & 3: Only one argument was provided
 982        if channel2 is None and channel3 is None and channel4 is None:
 983            # Option 2: Single integer
 984            if isinstance(channel1, int):
 985                channels_to_trigger = [channel1]
 986            # Option 3: List/Tuple of integers
 987            elif isinstance(channel1, (list, tuple, set)):
 988                channels_to_trigger = channel1
 989            else:
 990                channels_to_trigger = []
 991
 992            # Use bitwise shifts to calculate the trigger byte
 993            # (ch1=bit0, ch2=bit1, etc.)
 994            for ch in channels_to_trigger:
 995                if 1 <= ch <= 4:
 996                    trigger_byte |= (1 << (ch - 1))
 997
 998        # Option 1: Original input scheme (logicals for each channel)
 999        else:
1000            # Fallback to 0 if an argument was omitted via kwargs
1001            c1 = channel1 if channel1 is not None else 0
1002            c2 = channel2 if channel2 is not None else 0
1003            c3 = channel3 if channel3 is not None else 0
1004            c4 = channel4 if channel4 is not None else 0
1005
1006            trigger_byte = (
1007                (1 * c1)
1008                + (2 * c2)
1009                + (4 * c3)
1010                + (8 * c4)
1011            )
1012
1013        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
1014
1015    def sd_settings(self, settings_file_name, op):
1016        """Save, load, or delete a settings file on the microSD card.
1017
1018        A settings file holds a complete Pulse Pal program, so that it
1019        can be recalled later from software or from the device's front
1020        panel. Loading a file also refreshes the local copy of the
1021        parameters, via `PulsePalDevice.sync_from_device`.
1022
1023        ```python
1024        P.sd_settings("MyProtocol.pps", "save")
1025        ```
1026
1027        Args:
1028            settings_file_name: Settings file name, at most 15 ASCII
1029                characters including the required `.pps` extension.
1030            op: `"save"`, `"load"`, or `"delete"`.
1031
1032        Raises:
1033            PulsePalError: If the file name has no `.pps` extension or
1034                is too long, `op` is not one of the three operations, or
1035                the device does not acknowledge the command.
1036        """
1037        if ".pps" not in settings_file_name:
1038            raise PulsePalError(
1039                "Error: The file name must have a valid .pps extension."
1040            )
1041        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1042        try:
1043            op_byte = op_byte_by_name[str(op).lower()]
1044        except KeyError as exc:
1045            raise PulsePalError(
1046                "File op must be: 'save', 'load' or 'delete'."
1047            ) from exc
1048
1049        filename_bytes = settings_file_name.encode("ascii")
1050        if len(filename_bytes) > 15:
1051            raise PulsePalError("settings_file_name is too long.")
1052        self._write_serial(
1053            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1054            "uint8",
1055            list(filename_bytes),
1056            "uint8",
1057        )
1058        if self.info.firmware_version > 21:
1059            self._read_ack("sd_settings()")
1060        if op_byte == 2:
1061            time.sleep(0.1)
1062            self.sync_from_device()
1063
1064    def stop(self, channels=None):
1065        """Stop pulse trains currently playing on the device.
1066
1067        Every output channel returns to its
1068        `PulsePalDevice.resting_voltage`.
1069
1070        Args:
1071            channels (list or tuple, optional): A list of channels to stop, e.g.,
1072                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4. Default is None,
1073                which stops all channels. (Requires firmware v22+)
1074        """
1075        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1076
1077        if channels is not None:
1078            if self.info.firmware_version < 22:
1079                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1080
1081            # If a single integer is provided, wrap it in a list
1082            if isinstance(channels, int):
1083                channels = [channels]
1084
1085            bit_code = 0
1086            for ch in channels:
1087                if ch not in (1, 2, 3, 4):
1088                    raise ValueError("All channels must be valid Pulse Pal output channel indexes: 1, 2, 3 or 4")
1089
1090                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1091                bit_code |= 1 << (ch - 1)
1092
1093        # Send
1094        if self.info.firmware_version < 22:
1095            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1096        else:
1097            self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")
1098
1099    def format_microsd(self, timeout=30):
1100        """Format the device's microSD card.
1101
1102        Erases every settings file stored on the device and resets its
1103        parameters to the defaults. The user is prompted at the console
1104        to confirm before anything is erased.
1105
1106        Requires Pulse Pal hardware v3 or newer.
1107
1108        Args:
1109            timeout: Seconds to wait for the device to report that
1110                formatting has finished.
1111
1112        Returns:
1113            `None` once the card has been formatted, or an empty string
1114            if the user declines the confirmation prompt.
1115
1116        Raises:
1117            PulsePalError: If the connected hardware is older than v3.
1118        """
1119        if self.info.hardware_version < 3:
1120            raise PulsePalError(
1121                "format_microsd() requires hardware v3 or newer."
1122            )
1123
1124        print("*** Pulse Pal microSD Formatter ***")
1125        print("This will format Pulse Pal's microSD card,")
1126        print("erase all settings files on the device")
1127        print("and reset all parameters to defaults.")
1128
1129        reply = input("Do you want to continue (y/n)")
1130
1131        if reply.strip().lower() != "y":
1132            print("Choice confirmed - microSD Card NOT formatted.")
1133            return ""
1134
1135        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1136
1137        start = time.time()
1138        message = bytearray()
1139
1140        while time.time() - start < timeout:
1141            n_waiting = self.bytes_available()
1142            if n_waiting:
1143                message.extend(self.port.read(n_waiting))
1144                if ord("!") in message:
1145                    break
1146            time.sleep(0.01)
1147        raw_message = bytes(message)
1148        flag_index = raw_message.find(b"!")
1149        if flag_index >= 0:
1150            displayed_message = raw_message[:flag_index]
1151        else:
1152            displayed_message = raw_message
1153
1154        text = displayed_message.decode("ascii", errors="replace").rstrip()
1155        if text:
1156            print(text)
1157
1158        self.set_default_params()
1159        return None
1160
1161    def gui(self, block=None, theme=None):
1162        """Open the Pulse Pal parameter GUI, or focus an open one.
1163
1164        The GUI edits its own copy of the parameters, and loads them to
1165        the device when its 'Load to Device' button is clicked. The
1166        window closes automatically when the device is closed or
1167        deleted.
1168
1169        Calling this while the GUI is already open focuses the existing
1170        window rather than opening a second one.
1171
1172        Args:
1173            block: If `True`, the call returns when the GUI is closed. If
1174                `False`, the call returns immediately, and the host
1175                application must run the Tk event loop. If `None`, the
1176                GUI blocks only when the host does not already provide a
1177                Tk event loop, e.g. when launched from a script.
1178            theme: `"light"` or `"dark"` to select the color theme, or
1179                `None` to match the desktop theme. Passing a theme to an
1180                already-open GUI recolors it in place.
1181
1182        Returns:
1183            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1184
1185        Raises:
1186            ValueError: If the theme name is not recognized.
1187        """
1188        gui = getattr(self, "_gui", None)
1189        if gui is not None and not gui.is_closed:
1190            if theme is not None:
1191                gui.set_theme(theme)
1192            gui.focus()
1193            return gui
1194
1195        try:
1196            from .PulsePalGUI import PulsePalGUI
1197        except ImportError:
1198            from PulsePalGUI import PulsePalGUI
1199
1200        gui = PulsePalGUI(self, theme=theme)
1201        self._gui = gui
1202        gui.start(block=block)
1203        return gui
1204
1205    def close(self, send_disconnect=True):
1206        """Close the connection to the device, and the GUI if open.
1207
1208        Safe to call more than once; later calls do nothing. Called
1209        automatically when leaving a `with` block and when the object is
1210        garbage collected.
1211
1212        Args:
1213            send_disconnect: If `True`, tell the device that the client
1214                is disconnecting before closing the port. Set to `False`
1215                when the device is in an unknown state, such as after a
1216                failed handshake.
1217        """
1218        gui = getattr(self, "_gui", None)
1219        self._gui = None
1220        if gui is not None:
1221            try:
1222                gui.close()
1223            except Exception:
1224                # Cleanup must not raise; Tk may already be torn down
1225                pass
1226
1227        if getattr(self, "_closed", True):
1228            return
1229
1230        try:
1231            if send_disconnect and self.port and self.port.is_open:
1232                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1233        finally:
1234            if self.port and self.port.is_open:
1235                self.port.close()
1236            self._closed = True
1237
1238    def bytes_available(self):
1239        """Return the number of bytes waiting in the serial read buffer.
1240
1241        Returns:
1242            Count of bytes that can be read without blocking.
1243        """
1244        return self.port.in_waiting
1245
1246    def _get_output_param_code(self, param_name):
1247        """Resolve an output parameter name or code to its code."""
1248        if isinstance(param_name, str):
1249            try:
1250                return self.info.output_parameter_names.index(param_name) + 1
1251            except ValueError as exc:
1252                raise PulsePalError(
1253                    f"Unknown output parameter: {param_name}."
1254                ) from exc
1255        return int(param_name)
1256
1257    def _get_trigger_param_code(self, param_name):
1258        """Resolve a trigger parameter name or code to its code."""
1259        if isinstance(param_name, str):
1260            try:
1261                index = self.info.trigger_parameter_names.index(param_name)
1262                return index + 128
1263            except ValueError as exc:
1264                raise PulsePalError(
1265                    f"Unknown trigger parameter: {param_name}."
1266                ) from exc
1267        return int(param_name)
1268
1269    def _write_serial(self, *args):
1270        """Write one or more data/type pairs to the serial port."""
1271        if len(args) % 2 != 0:
1272            raise PulsePalError(
1273                "Serial writes require data/type argument pairs."
1274            )
1275
1276        payload = bytearray()
1277        for i in range(0, len(args), 2):
1278            payload.extend(self._pack_values(args[i], args[i + 1]))
1279
1280        bytes_written = self.port.write(bytes(payload))
1281        if bytes_written != len(payload):
1282            raise PulsePalError(
1283                f"Error: wrote {bytes_written} byte(s), expected to write "
1284                f"{len(payload)} byte(s)."
1285            )
1286
1287    def _read_serial(self, n_values, datatype):
1288        """Read values from the serial port and unpack them with struct."""
1289        datatype = self._normalize_datatype(datatype)
1290        fmt = self._STRUCT_FORMATS[datatype]
1291        n_values = int(n_values)
1292        n_bytes = n_values * struct.calcsize(fmt)
1293        message_bytes = self.port.read(n_bytes)
1294        if len(message_bytes) < n_bytes:
1295            raise PulsePalError(
1296                f"Error: serial port timed out. "
1297                f"{len(message_bytes)} byte(s) read. "
1298                f"Expected {n_bytes} byte(s)."
1299            )
1300
1301        values = struct.unpack(
1302            f"{self._ENDIANNESS}{n_values}{fmt}",
1303            message_bytes,
1304        )
1305        if n_values == 1:
1306            return values[0]
1307        return list(values)
1308
1309    def _read_ack(self, context):
1310        """Read a one-byte acknowledgement from the device."""
1311        try:
1312            self._read_serial(1, "uint8")
1313        except PulsePalError as exc:
1314            raise PulsePalError(
1315                "Error: Pulse Pal did not return an acknowledgement byte "
1316                f"after a call to {context}."
1317            ) from exc
1318
1319    def _pack_values(self, values, datatype):
1320        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1321        datatype = self._normalize_datatype(datatype)
1322        fmt = self._STRUCT_FORMATS[datatype]
1323        values_list = self._as_list(values)
1324
1325        if datatype == "char":
1326            values_list = self._normalize_char_values(values_list)
1327        elif datatype in self._TYPE_RANGES:
1328            values_list = self._normalize_int_values(values_list, datatype)
1329        else:
1330            values_list = [float(value) for value in values_list]
1331
1332        return struct.pack(
1333            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1334            *values_list,
1335        )
1336
1337    def _normalize_datatype(self, datatype):
1338        """Return the datatype name, rejecting unsupported types."""
1339        datatype = str(datatype)
1340        if datatype not in self._STRUCT_FORMATS:
1341            raise PulsePalError(
1342                f"Error: {datatype} is not a data type supported by "
1343                "PulsePalObject."
1344            )
1345        return datatype
1346
1347    def _normalize_char_values(self, values):
1348        """Coerce str, int and bytes values to single ASCII bytes."""
1349        normalized = []
1350        for value in values:
1351            if isinstance(value, str):
1352                value = value.encode("ascii")
1353            if isinstance(value, int):
1354                value = bytes((value,))
1355            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1356                raise PulsePalError(
1357                    "char values must be one-byte bytes, chars, or "
1358                    "integers."
1359                )
1360            normalized.append(bytes(value))
1361        return normalized
1362
1363    def _normalize_int_values(self, values, datatype):
1364        """Coerce values to ints, rejecting any out of range."""
1365        min_value, max_value = self._TYPE_RANGES[datatype]
1366        normalized = []
1367        for value in values:
1368            value = int(value)
1369            if not min_value <= value <= max_value:
1370                raise PulsePalError(
1371                    f"Value {value} is out of range for {datatype} "
1372                    f"({min_value} to {max_value})."
1373                )
1374            normalized.append(value)
1375        return normalized
1376
1377    def _as_list(self, values):
1378        """Return values as a flat list, wrapping scalars in one."""
1379        if isinstance(values, np.ndarray):
1380            return values.ravel().tolist()
1381        if isinstance(values, (bytes, bytearray, str)):
1382            return [values]
1383        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1384            return [values]
1385        try:
1386            return list(values)
1387        except TypeError:
1388            return [values]
1389
1390    def _set_output_param_value(self, param_code, channel, original_value):
1391        """Store a programmed value in its local parameter array."""
1392        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1393        if attr_name is None:
1394            return
1395        values = getattr(self, attr_name)
1396        values[channel] = original_value
1397
1398    def _to_decimal(self, value):
1399        """Convert a value to a Decimal with PulsePal precision."""
1400        return Decimal(value).quantize(Decimal("1.0000"))
1401
1402    def _volts_to_bits(self, value):
1403        """Convert -10 V to +10 V to the corresponding DAC bit value."""
1404        normalized = (float(value) + 10) / 20
1405        bit_max = int(self._dac_bit_max)
1406        return int(min(max(round(normalized * bit_max), 0), bit_max))
1407
1408    def _bits_to_volts(self, value):
1409        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1410        bit_max = int(self._dac_bit_max)
1411        raw_volts = (float(value) / bit_max * 20) - 10
1412
1413        # Calculate the voltage of 1 bit
1414        lsb_volts = 20.0 / bit_max
1415
1416        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1417        clean_volts = round(raw_volts, 3)
1418
1419        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1420        if abs(raw_volts - clean_volts) <= lsb_volts:
1421            return clean_volts
1422
1423        # Otherwise, return the standard 4-decimal reading
1424        return round(raw_volts, 4)
1425
1426    def _seconds_to_cycles(self, value):
1427        """Convert seconds to the corresponding refresh-cycle count."""
1428        return int(round(float(value) * float(self.info.cycle_frequency)))
1429
1430    def _cycles_to_seconds(self, value):
1431        """Convert hardware timer cycle counts to seconds."""
1432        return float(value) / float(self.info.cycle_frequency)
1433
1434    def _require_firmware(self, minimum_version, context):
1435        """Raise unless the device firmware is new enough for an op."""
1436        if (
1437            self.info.firmware_version is None
1438            or self.info.firmware_version < minimum_version
1439        ):
1440            raise PulsePalError(
1441                f"{context} requires firmware v{minimum_version} or newer. "
1442                f"Detected firmware is v{self.info.firmware_version}."
1443            )
1444
1445    def _sync_all_params(self):
1446        """Send all parameters using the packed sync op (firmware v22+).
1447
1448        Values are grouped by width so that the whole program travels as
1449        one uint32 block, one uint16 block and one uint8 block.
1450        """
1451        time_values = []
1452        for attr_name in (
1453            "phase1_duration",
1454            "inter_phase_interval",
1455            "phase2_duration",
1456            "inter_pulse_interval",
1457            "burst_duration",
1458            "inter_burst_interval",
1459            "pulse_train_duration",
1460            "pulse_train_delay",
1461        ):
1462            time_values.extend(
1463                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1464                for channel in range(1, 5)
1465            )
1466
1467        voltage_values = []
1468        for attr_name in (
1469            "phase1_voltage",
1470            "phase2_voltage",
1471            "resting_voltage",
1472        ):
1473            voltage_values.extend(
1474                self._volts_to_bits(getattr(self, attr_name)[channel])
1475                for channel in range(1, 5)
1476            )
1477
1478        single_byte_values = []
1479        for attr_name in (
1480            "is_biphasic",
1481            "custom_train_id",
1482            "custom_train_target",
1483            "custom_train_loop",
1484            "playback_mode",
1485        ):
1486            single_byte_values.extend(
1487                int(getattr(self, attr_name)[channel])
1488                for channel in range(1, 5)
1489            )
1490        single_byte_values.extend(
1491            int(self.link_trigger_channel1[channel])
1492            for channel in range(1, 5)
1493        )
1494        single_byte_values.extend(
1495            int(self.link_trigger_channel2[channel])
1496            for channel in range(1, 5)
1497        )
1498        single_byte_values.extend(
1499            int(value) for value in self.trigger_mode[1:3]
1500        )
1501
1502        self._write_serial(
1503            (self._OP_MENU_BYTE, 92),
1504            "uint8",
1505            time_values,
1506            "uint32",
1507            voltage_values,
1508            "uint16",
1509            single_byte_values,
1510            "uint8",
1511        )
1512
1513    def _sync_all_params_legacy(self):
1514        """Send all parameters using the legacy sync op (firmware v21).
1515
1516        Equivalent to `_sync_all_params`, but lays the program out
1517        channel by channel as the older firmware expects.
1518        """
1519        program_values_16 = []
1520        program_values_32 = []
1521        program_values_8 = [0] * 16
1522
1523        for channel in range(1, 5):
1524            program_values_32.extend(
1525                [
1526                    self._seconds_to_cycles(
1527                        self.phase1_duration[channel]),
1528                    self._seconds_to_cycles(
1529                        self.inter_phase_interval[channel]),
1530                    self._seconds_to_cycles(
1531                        self.phase2_duration[channel]),
1532                    self._seconds_to_cycles(
1533                        self.inter_pulse_interval[channel]),
1534                    self._seconds_to_cycles(
1535                        self.burst_duration[channel]),
1536                    self._seconds_to_cycles(
1537                        self.inter_burst_interval[channel]),
1538                    self._seconds_to_cycles(
1539                        self.pulse_train_duration[channel]),
1540                    self._seconds_to_cycles(
1541                        self.pulse_train_delay[channel]),
1542                ]
1543            )
1544
1545        for channel in range(1, 5):
1546            program_values_16.extend(
1547                [
1548                    self._volts_to_bits(self.phase1_voltage[channel]),
1549                    self._volts_to_bits(self.phase2_voltage[channel]),
1550                    self._volts_to_bits(self.resting_voltage[channel]),
1551                ]
1552            )
1553
1554        position = 0
1555        for channel in range(1, 5):
1556            program_values_8[position] = self.is_biphasic[channel]
1557            position += 1
1558            program_values_8[position] = self.custom_train_id[channel]
1559            position += 1
1560            program_values_8[position] = self.custom_train_target[channel]
1561            position += 1
1562            program_values_8[position] = self.custom_train_loop[channel]
1563            position += 1
1564
1565        program_values_tl = [0] * 8
1566        position = 0
1567        for channel in range(1, 5):
1568            program_values_tl[position] = self.link_trigger_channel1[channel]
1569            position += 1
1570        for channel in range(1, 5):
1571            program_values_tl[position] = self.link_trigger_channel2[channel]
1572            position += 1
1573
1574        self._write_serial(
1575            (self._OP_MENU_BYTE, 73),
1576            "uint8",
1577            program_values_32,
1578            "uint32",
1579            program_values_16,
1580            "uint16",
1581            program_values_8,
1582            "uint8",
1583            program_values_tl,
1584            "uint8",
1585            self.trigger_mode[1:3],
1586            "uint8",
1587        )
1588
1589    def __enter__(self):
1590        """Enter a `with` block, returning the connected device."""
1591        return self
1592
1593    def __exit__(self, exc_type, exc_value, traceback):
1594        """Disconnect and close the port when leaving a `with` block.
1595
1596        Returns:
1597            `False`, so any exception raised in the block propagates.
1598        """
1599        try:
1600            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1601        except Exception:
1602            pass
1603        self.close()
1604        return False
1605
1606    def __del__(self):
1607        """Disconnect and close the port when the object is collected."""
1608        try:
1609            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1610        except Exception:
1611            pass
1612
1613        try:
1614            self.close()
1615        except Exception:
1616            # Destructors should not raise; the serial object may already be
1617            # gone during interpreter shutdown.
1618            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)
393    def __init__(self, port_name, baud_rate=12000000, timeout=10):
394        """Open a connection to a Pulse Pal device.
395
396        Opens the serial port, exchanges the handshake, verifies the
397        firmware version, reads the device properties into
398        `PulsePalDevice.info`, and programs the device with the default
399        parameters.
400
401        Args:
402            port_name: USB serial port for the Pulse Pal device, such as
403                `COM3` on Windows or `/dev/ttyACM0` on Linux.
404            baud_rate: Serial baud rate.
405            timeout: Serial read timeout, in seconds.
406
407        Raises:
408            PulsePalError: If the device does not return the expected
409                handshake, or its firmware is older than v21, or its
410                firmware is newer than this module supports.
411            serial.SerialException: If the serial port cannot be opened.
412        """
413        self.info = DeviceInfo()
414        self._gui = None
415        self.port = serial.Serial(
416            port_name,
417            baud_rate,
418            timeout=timeout,
419            rtscts=True,
420        )
421        self._closed = False
422        self._dac_bit_max = self._to_decimal(0)
423        self.info.firmware_version = None
424        self.info.hardware_version = None
425        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
426        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
427
428        self._write_serial(
429            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
430            "uint8",
431        )
432        handshake = self._read_serial(1, "uint8")
433        if handshake != self._HANDSHAKE_RESPONSE:
434            self.close(send_disconnect=False)
435            raise PulsePalError(
436                "Error: incorrect handshake returned. Expected "
437                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
438            )
439
440        firmware_version = self._read_serial(1, "uint32")
441        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
442            raise PulsePalError(
443                "Error: Old firmware detected, v"
444                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
445                "newer is required."
446            )
447        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
448            raise PulsePalError(
449                "Error: Future firmware detected, v"
450                f"{firmware_version}. Please update PulsePal.py or downgrade "
451                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
452            )
453        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
454            print(
455                "Old firmware detected, v"
456                f"{firmware_version}. This firmware is supported. Update to v"
457                f"{self._CURRENT_FIRMWARE_VERSION} is available."
458            )
459        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
460        self.info.firmware_version = firmware_version
461
462        if self.info.firmware_version > 21:
463            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
464            self.info.hardware_version = self._read_serial(1, "uint8")
465            self.info.cycle_period_us = self._read_serial(1, "uint32")
466            self.info.cycle_frequency = 1 / (
467                self.info.cycle_period_us / 1000000
468            )
469            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
470            self.info.max_custom_pulses = self._read_serial(1, "uint32")
471        else:
472            self.info.hardware_version = 2
473            self.info.cycle_period_us = 50
474            self.info.cycle_frequency = 20000
475            self.info.n_custom_pulse_trains = 2
476            self.info.max_custom_pulses = 5000
477
478        # Client name op + "PYTHON" in ASCII.
479        self._write_serial(
480            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
481            "uint8",
482        )
483
484        self.set_default_params()
485        self.sync_to_device()

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.
  • 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].

phase1_duration: list

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

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.

inter_pulse_interval: list

Interval from the end of one pulse to the onset of the next, in seconds.

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.

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.

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.
@staticmethod
def serialportlist(ports_to_list='available'):
487    @staticmethod
488    def serialportlist(ports_to_list="available"):
489        """Return the names of the USB serial ports on this computer.
490
491        Called on the class, without connecting to a device, to find the
492        port name to pass to `PulsePalDevice`:
493
494        ```python
495        from PulsePal import PulsePalDevice
496
497        ports = PulsePalDevice.serialportlist()
498        P = PulsePalDevice(ports[0])
499        ```
500
501        Args:
502            ports_to_list: `available` to list only the ports that are
503                not already in use, or `all` to list every USB serial
504                port. Not case sensitive.
505
506        Returns:
507            Sorted list of port names, such as `["COM3", "COM7"]` on
508            Windows or `["/dev/ttyACM0"]` on Linux.
509
510        Raises:
511            PulsePalError: If `ports_to_list` is not `available` or
512                `all`.
513        """
514        if not isinstance(ports_to_list, str):
515            raise PulsePalError(
516                "serialportlist() takes 'available' or 'all'."
517            )
518        mode = ports_to_list.lower()
519        if mode not in ("available", "all"):
520            raise PulsePalError(
521                f"Unknown port list type: {ports_to_list}. "
522                "Use 'available' or 'all'."
523            )
524
525        port_names = []
526        for port_info in serial.tools.list_ports.comports():
527            is_usb = port_info.vid is not None or "USB" in (
528                port_info.hwid or ""
529            ).upper()
530            if not is_usb:
531                continue
532            if mode == "available" and not PulsePalDevice._port_is_free(
533                port_info.device
534            ):
535                continue
536            port_names.append(port_info.device)
537        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):
555    def set_default_params(self):
556        """Reset the local copy of all parameters to their defaults.
557
558        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
559        1 second, on all four output channels, linked to trigger channel
560        1 in normal trigger mode.
561
562        This updates only the local copy. Call
563        `PulsePalDevice.sync_to_device` to program the device with them.
564        """
565        nan = float("nan")
566        self.is_biphasic = [nan, 0, 0, 0, 0]
567        self.phase1_voltage = [nan, 5, 5, 5, 5]
568        self.phase2_voltage = [nan, -5, -5, -5, -5]
569        self.resting_voltage = [nan, 0, 0, 0, 0]
570        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
571        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
572        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
573        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
574        self.burst_duration = [nan, 0, 0, 0, 0]
575        self.inter_burst_interval = [nan, 0, 0, 0, 0]
576        self.pulse_train_duration = [nan, 1, 1, 1, 1]
577        self.pulse_train_delay = [nan, 0, 0, 0, 0]
578        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
579        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
580        self.custom_train_id = [nan, 0, 0, 0, 0]
581        self.custom_train_target = [nan, 0, 0, 0, 0]
582        self.custom_train_loop = [nan, 0, 0, 0, 0]
583        self.playback_mode = [nan, 0, 0, 0, 0]
584        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):
586    def set_voltage(self, channel, voltage):
587        """Set an output channel to a fixed voltage.
588
589        The channel holds the voltage until it is set again or until a
590        pulse train is triggered on it.
591
592        Args:
593            channel: Output channel number, 1-4.
594            voltage: Voltage to set, in volts [-10, 10].
595
596        Raises:
597            PulsePalError: If the device does not acknowledge the
598                command.
599        """
600        voltage_bits = self._volts_to_bits(voltage)
601        self._write_serial(
602            (self._OP_MENU_BYTE, 79, channel),
603            "uint8",
604            voltage_bits,
605            "uint16",
606        )
607        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 device does not acknowledge the command.
def set_calibration(self, channel, voltage_offset):
609    def set_calibration(self, channel, voltage_offset):
610        """Calibrate the zero code of an output channel.
611
612        The offset is added to every voltage the channel produces, to
613        correct for DAC offset error. It is stored in the device's
614        EEPROM and reloaded on boot, so it only needs to be set once.
615
616        Requires Pulse Pal hardware v3 or newer.
617
618        Args:
619            channel: Output channel number, 1-4.
620            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
621
622        Raises:
623            PulsePalError: If the connected hardware is older than v3,
624                or the device does not acknowledge the command.
625            ValueError: If `channel` is not 1-4, or `voltage_offset` is
626                outside [-0.1, 0.1].
627        """
628        if self.info.hardware_version < 3:
629            raise PulsePalError(
630                "set_calibration() requires hardware v3 or newer."
631            )
632        if channel not in (1, 2, 3, 4):
633            raise ValueError("channel must be 1, 2, 3 or 4")
634        if voltage_offset < -0.1 or voltage_offset > 0.1:
635            raise ValueError(
636                "voltage_offset for zero code calibration must be in range "
637                "[-0.1, 0.1]"
638            )
639        voltage_bits = voltage_offset * (1 / (20 / 65536))
640        self._write_serial(
641            (self._OP_MENU_BYTE, 96, channel - 1),
642            "uint8",
643            voltage_bits,
644            "int16",
645        )
646        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):
648    def set_output_param(self, param_name, channel, value):
649        """Program a single output channel parameter on the device.
650
651        The local copy of the parameter is updated to match, so a later
652        `PulsePalDevice.sync_to_device` will not undo the change.
653
654        ```python
655        P.set_output_param("is_biphasic", 1, 1)
656        P.set_output_param("phase1_voltage", 1, 10)
657        P.set_output_param(3, 1, -10)   # same, by param code
658        ```
659
660        Args:
661            param_name: Parameter name, as listed in
662                `DeviceInfo.output_parameter_names`, or its integer
663                parameter code.
664            channel: Output channel number, 1-4.
665            value: Value to set. Units are volts for voltage parameters,
666                seconds for time parameters, and integers for enumerated
667                parameters. See the attributes of `PulsePalDevice` for
668                the meaning of each parameter.
669
670        Raises:
671            PulsePalError: If the parameter name is not recognized, the
672                value does not fit the datatype the device expects, or
673                the device does not acknowledge the command.
674        """
675        original_value = value
676        param_code = self._get_output_param_code(param_name)
677
678        if param_code in (2, 3, 17):
679            value = self._volts_to_bits(value)
680            self._write_serial(
681                (self._OP_MENU_BYTE, 74, param_code, channel),
682                "uint8",
683                value,
684                "uint16",
685            )
686        elif 4 <= param_code <= 11:
687            self._write_serial(
688                (self._OP_MENU_BYTE, 74, param_code, channel),
689                "uint8",
690                self._seconds_to_cycles(value),
691                "uint32",
692            )
693        else:
694            self._write_serial(
695                (self._OP_MENU_BYTE, 74, param_code, channel, value),
696                "uint8",
697            )
698
699        self._read_ack("program_output_channel_param()")
700        self._set_output_param_value(param_code, channel, original_value)

Program a single 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
Arguments:
  • param_name: Parameter name, as listed in DeviceInfo.output_parameter_names, or its integer parameter code.
  • channel: Output channel number, 1-4.
  • 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.
Raises:
  • PulsePalError: If the parameter name is not recognized, the value does not fit the datatype the device expects, or the device does not acknowledge the command.
def set_trigger_param(self, param_name, channel, value):
702    def set_trigger_param(self, param_name, channel, value):
703        """Program a single trigger channel parameter on the device.
704
705        The local copy of the parameter is updated to match, so a later
706        `PulsePalDevice.sync_to_device` will not undo the change.
707
708        ```python
709        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
710        ```
711
712        Args:
713            param_name: Parameter name, as listed in
714                `DeviceInfo.trigger_parameter_names`, or its integer
715                parameter code.
716            channel: Trigger channel number, 1-2.
717            value: Value to set. See `PulsePalDevice.trigger_mode` for
718                the trigger modes.
719
720        Raises:
721            PulsePalError: If the parameter name is not recognized, the
722                value does not fit the datatype the device expects, or
723                the device does not acknowledge the command.
724        """
725        original_value = value
726        param_code = self._get_trigger_param_code(param_name)
727
728        self._write_serial(
729            (self._OP_MENU_BYTE, 74, param_code, channel, value),
730            "uint8",
731        )
732        self._read_ack("program_trigger_channel_param()")
733
734        if param_code in (1, 128):
735            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 value does not fit the datatype the device expects, or the device does not acknowledge the command.
def sync_to_device(self):
737    def sync_to_device(self):
738        """Program the device with the local copy of all parameters.
739
740        Call this after assigning to the parameter array attributes, to
741        send every output and trigger parameter to the device in a
742        single transaction.
743
744        ```python
745        P.phase1_voltage[1:5] = [5] * 4
746        P.sync_to_device()
747        ```
748
749        Raises:
750            PulsePalError: If the device does not acknowledge the
751                command.
752        """
753        # _sync_all_params() for firmware v22+ uses the newer packed sync
754        # opcode (92). _sync_all_params_legacy() uses the less efficient
755        # legacy packed sync opcode (73).
756        if self.info.firmware_version > 21:
757            self._sync_all_params()
758        else:
759            self._sync_all_params_legacy()
760        self._read_ack("sync_to_device()")

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()
Raises:
  • PulsePalError: If the device does not acknowledge the command.
def sync_from_device(self):
762    def sync_from_device(self):
763        """Read all parameters from the device into the local copy.
764
765        Overwrites every parameter array attribute with the program
766        currently stored on the device. Useful after the device has been
767        reprogrammed from its thumb joystick.
768
769        Requires firmware v22 or newer.
770
771        Raises:
772            PulsePalError: If the connected firmware is older than v22,
773                or the device does not return the full parameter set.
774        """
775        self._require_firmware(22, "sync_from_device()")
776        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
777        for attr_name in (
778                "phase1_duration",
779                "inter_phase_interval",
780                "phase2_duration",
781                "inter_pulse_interval",
782                "burst_duration",
783                "inter_burst_interval",
784                "pulse_train_duration",
785                "pulse_train_delay",
786        ):
787            setattr(
788                self,
789                attr_name,
790                [float("nan")]
791                + [
792                    self._cycles_to_seconds(x)
793                    for x in self._read_serial(4, "uint32")
794                ],
795            )
796
797        for attr_name in (
798                "phase1_voltage",
799                "phase2_voltage",
800                "resting_voltage",
801        ):
802            setattr(
803                self,
804                attr_name,
805                [float("nan")]
806                + [
807                    self._bits_to_volts(x)
808                    for x in self._read_serial(4, "uint16")
809                ],
810            )
811
812        for attr_name in (
813                "is_biphasic",
814                "custom_train_id",
815                "custom_train_target",
816                "custom_train_loop",
817                "link_trigger_channel1",
818                "link_trigger_channel2",
819        ):
820            setattr(
821                self,
822                attr_name,
823                [float("nan")] + self._read_serial(4, "uint8"),
824            )
825        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")

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):
827    def send_custom_pulse_train(
828        self,
829        custom_train_id,
830        pulse_times,
831        pulse_voltages,
832    ):
833        """Load a custom pulse train onto the device.
834
835        A custom pulse train is an arbitrary list of pulse onset times
836        and voltages, replacing the parametric pulse voltage and onset timing.
837        Set `PulsePalDevice.custom_train_id` on an output channel to play
838        the train there.
839
840        ```python
841        P.send_custom_pulse_train(
842            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
843        )
844        P.set_output_param("custom_train_id", 1, 2)
845        ```
846
847        Args:
848            custom_train_id: Custom train to load, 1-2. See
849                `DeviceInfo.n_custom_pulse_trains`.
850            pulse_times: Pulse onset times, in seconds, relative to the
851                start of the train. Accepts a list, tuple or NumPy
852                array.
853            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
854                Must be the same length as `pulse_times`.
855
856        Raises:
857            PulsePalError: If `pulse_times` and `pulse_voltages` differ
858                in length, or the device does not acknowledge the
859                command.
860        """
861        pulse_times = self._as_list(pulse_times)
862        pulse_voltages = self._as_list(pulse_voltages)
863        n_pulses = len(pulse_times)
864        if n_pulses != len(pulse_voltages):
865            raise PulsePalError(
866                "pulse_times and pulse_voltages must be the same length."
867            )
868
869        pulse_times_cycles = [
870            self._seconds_to_cycles(pulse_time)
871            for pulse_time in pulse_times
872        ]
873        pulse_voltage_bits = [
874            self._volts_to_bits(voltage)
875            for voltage in pulse_voltages
876        ]
877
878        op_code = int(custom_train_id) + 74
879        self._write_serial(
880            (self._OP_MENU_BYTE, op_code),
881            "uint8",
882            n_pulses,
883            "uint32",
884            pulse_times_cycles,
885            "uint32",
886            pulse_voltage_bits,
887            "uint16",
888        )
889        self._read_ack("send_custom_pulse_train()")

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, 1-2. See DeviceInfo.n_custom_pulse_trains.
  • 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:
  • PulsePalError: If pulse_times and pulse_voltages differ in length, or the device does not acknowledge the command.
def send_custom_waveform(self, custom_train_id, pulse_width, pulse_voltages):
891    def send_custom_waveform(
892        self,
893        custom_train_id,
894        pulse_width,
895        pulse_voltages,
896    ):
897        """Load an arbitrary waveform onto the device.
898
899        A convenience shorthand for
900        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
901        confluent pulses, so that `pulse_voltages` is played as a
902        waveform sampled every `pulse_width` seconds.
903
904        Set the channel's `PulsePalDevice.phase1_duration` to
905        `pulse_width` as well, so that each sample is held for the
906        sampling period.
907
908        ```python
909        import math
910
911        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
912        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
913        P.set_output_param("custom_train_id", 2, 1)
914        P.set_output_param("phase1_duration", 2, 0.001)
915        ```
916
917        Args:
918            custom_train_id: Custom train to load, 1-2. See
919                `DeviceInfo.n_custom_pulse_trains`.
920            pulse_width: Sampling period, in seconds. Each voltage is
921                held for this long.
922            pulse_voltages: Waveform samples, in volts [-10, 10].
923                Accepts a list, tuple or NumPy array.
924
925        Raises:
926            PulsePalError: If the device does not acknowledge the
927                command.
928        """
929        pulse_voltages = self._as_list(pulse_voltages)
930        n_pulses = len(pulse_voltages)
931        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
932        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
933        pulse_voltage_bits = [
934            self._volts_to_bits(voltage)
935            for voltage in pulse_voltages
936        ]
937
938        op_code = int(custom_train_id) + 74
939        self._write_serial(
940            (self._OP_MENU_BYTE, op_code),
941            "uint8",
942            n_pulses,
943            "uint32",
944            pulse_times,
945            "uint32",
946            pulse_voltage_bits,
947            "uint16",
948        )
949        self._read_ack("send_custom_waveform()")

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, 1-2. See DeviceInfo.n_custom_pulse_trains.
  • 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:
  • PulsePalError: If the device does not acknowledge the command.
def trigger(self, channel1=None, channel2=None, channel3=None, channel4=None):
 951    def trigger(
 952        self,
 953        channel1=None,
 954        channel2=None,
 955        channel3=None,
 956        channel4=None,
 957    ):
 958        """Trigger output channels in software.
 959
 960        Triggered channels start their pulse trains together. Three
 961        calling conventions are accepted:
 962
 963        ```python
 964        P.trigger(1, 0, 1, 0)   # one flag per channel
 965        P.trigger(3)            # a single channel number
 966        P.trigger([1, 4])       # several channel numbers
 967        ```
 968
 969        Channel numbers outside 1-4 are ignored.
 970
 971        Args:
 972            channel1: `1` to trigger channel 1, otherwise `0`; or, when
 973                it is the only argument given, a single channel number
 974                or a list of channel numbers to trigger.
 975            channel2: `1` to trigger channel 2, otherwise `0`.
 976            channel3: `1` to trigger channel 3, otherwise `0`.
 977            channel4: `1` to trigger channel 4, otherwise `0`.
 978        """
 979        trigger_byte = 0
 980
 981        # Options 2 & 3: Only one argument was provided
 982        if channel2 is None and channel3 is None and channel4 is None:
 983            # Option 2: Single integer
 984            if isinstance(channel1, int):
 985                channels_to_trigger = [channel1]
 986            # Option 3: List/Tuple of integers
 987            elif isinstance(channel1, (list, tuple, set)):
 988                channels_to_trigger = channel1
 989            else:
 990                channels_to_trigger = []
 991
 992            # Use bitwise shifts to calculate the trigger byte
 993            # (ch1=bit0, ch2=bit1, etc.)
 994            for ch in channels_to_trigger:
 995                if 1 <= ch <= 4:
 996                    trigger_byte |= (1 << (ch - 1))
 997
 998        # Option 1: Original input scheme (logicals for each channel)
 999        else:
1000            # Fallback to 0 if an argument was omitted via kwargs
1001            c1 = channel1 if channel1 is not None else 0
1002            c2 = channel2 if channel2 is not None else 0
1003            c3 = channel3 if channel3 is not None else 0
1004            c4 = channel4 if channel4 is not None else 0
1005
1006            trigger_byte = (
1007                (1 * c1)
1008                + (2 * c2)
1009                + (4 * c3)
1010                + (8 * c4)
1011            )
1012
1013        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 outside 1-4 are ignored.

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.
def sd_settings(self, settings_file_name, op):
1015    def sd_settings(self, settings_file_name, op):
1016        """Save, load, or delete a settings file on the microSD card.
1017
1018        A settings file holds a complete Pulse Pal program, so that it
1019        can be recalled later from software or from the device's front
1020        panel. Loading a file also refreshes the local copy of the
1021        parameters, via `PulsePalDevice.sync_from_device`.
1022
1023        ```python
1024        P.sd_settings("MyProtocol.pps", "save")
1025        ```
1026
1027        Args:
1028            settings_file_name: Settings file name, at most 15 ASCII
1029                characters including the required `.pps` extension.
1030            op: `"save"`, `"load"`, or `"delete"`.
1031
1032        Raises:
1033            PulsePalError: If the file name has no `.pps` extension or
1034                is too long, `op` is not one of the three operations, or
1035                the device does not acknowledge the command.
1036        """
1037        if ".pps" not in settings_file_name:
1038            raise PulsePalError(
1039                "Error: The file name must have a valid .pps extension."
1040            )
1041        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
1042        try:
1043            op_byte = op_byte_by_name[str(op).lower()]
1044        except KeyError as exc:
1045            raise PulsePalError(
1046                "File op must be: 'save', 'load' or 'delete'."
1047            ) from exc
1048
1049        filename_bytes = settings_file_name.encode("ascii")
1050        if len(filename_bytes) > 15:
1051            raise PulsePalError("settings_file_name is too long.")
1052        self._write_serial(
1053            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1054            "uint8",
1055            list(filename_bytes),
1056            "uint8",
1057        )
1058        if self.info.firmware_version > 21:
1059            self._read_ack("sd_settings()")
1060        if op_byte == 2:
1061            time.sleep(0.1)
1062            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.
def stop(self, channels=None):
1064    def stop(self, channels=None):
1065        """Stop pulse trains currently playing on the device.
1066
1067        Every output channel returns to its
1068        `PulsePalDevice.resting_voltage`.
1069
1070        Args:
1071            channels (list or tuple, optional): A list of channels to stop, e.g.,
1072                [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4. Default is None,
1073                which stops all channels. (Requires firmware v22+)
1074        """
1075        bit_code = 15  # Default: 15 (binary 1111) stops all channels
1076
1077        if channels is not None:
1078            if self.info.firmware_version < 22:
1079                raise ValueError("stop() cannot address individual channels prior to firmware v22")
1080
1081            # If a single integer is provided, wrap it in a list
1082            if isinstance(channels, int):
1083                channels = [channels]
1084
1085            bit_code = 0
1086            for ch in channels:
1087                if ch not in (1, 2, 3, 4):
1088                    raise ValueError("All channels must be valid Pulse Pal output channel indexes: 1, 2, 3 or 4")
1089
1090                # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries
1091                bit_code |= 1 << (ch - 1)
1092
1093        # Send
1094        if self.info.firmware_version < 22:
1095            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
1096        else:
1097            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. Default is None, which stops all channels. (Requires firmware v22+)
def format_microsd(self, timeout=30):
1099    def format_microsd(self, timeout=30):
1100        """Format the device's microSD card.
1101
1102        Erases every settings file stored on the device and resets its
1103        parameters to the defaults. The user is prompted at the console
1104        to confirm before anything is erased.
1105
1106        Requires Pulse Pal hardware v3 or newer.
1107
1108        Args:
1109            timeout: Seconds to wait for the device to report that
1110                formatting has finished.
1111
1112        Returns:
1113            `None` once the card has been formatted, or an empty string
1114            if the user declines the confirmation prompt.
1115
1116        Raises:
1117            PulsePalError: If the connected hardware is older than v3.
1118        """
1119        if self.info.hardware_version < 3:
1120            raise PulsePalError(
1121                "format_microsd() requires hardware v3 or newer."
1122            )
1123
1124        print("*** Pulse Pal microSD Formatter ***")
1125        print("This will format Pulse Pal's microSD card,")
1126        print("erase all settings files on the device")
1127        print("and reset all parameters to defaults.")
1128
1129        reply = input("Do you want to continue (y/n)")
1130
1131        if reply.strip().lower() != "y":
1132            print("Choice confirmed - microSD Card NOT formatted.")
1133            return ""
1134
1135        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1136
1137        start = time.time()
1138        message = bytearray()
1139
1140        while time.time() - start < timeout:
1141            n_waiting = self.bytes_available()
1142            if n_waiting:
1143                message.extend(self.port.read(n_waiting))
1144                if ord("!") in message:
1145                    break
1146            time.sleep(0.01)
1147        raw_message = bytes(message)
1148        flag_index = raw_message.find(b"!")
1149        if flag_index >= 0:
1150            displayed_message = raw_message[:flag_index]
1151        else:
1152            displayed_message = raw_message
1153
1154        text = displayed_message.decode("ascii", errors="replace").rstrip()
1155        if text:
1156            print(text)
1157
1158        self.set_default_params()
1159        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.
def gui(self, block=None, theme=None):
1161    def gui(self, block=None, theme=None):
1162        """Open the Pulse Pal parameter GUI, or focus an open one.
1163
1164        The GUI edits its own copy of the parameters, and loads them to
1165        the device when its 'Load to Device' button is clicked. The
1166        window closes automatically when the device is closed or
1167        deleted.
1168
1169        Calling this while the GUI is already open focuses the existing
1170        window rather than opening a second one.
1171
1172        Args:
1173            block: If `True`, the call returns when the GUI is closed. If
1174                `False`, the call returns immediately, and the host
1175                application must run the Tk event loop. If `None`, the
1176                GUI blocks only when the host does not already provide a
1177                Tk event loop, e.g. when launched from a script.
1178            theme: `"light"` or `"dark"` to select the color theme, or
1179                `None` to match the desktop theme. Passing a theme to an
1180                already-open GUI recolors it in place.
1181
1182        Returns:
1183            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1184
1185        Raises:
1186            ValueError: If the theme name is not recognized.
1187        """
1188        gui = getattr(self, "_gui", None)
1189        if gui is not None and not gui.is_closed:
1190            if theme is not None:
1191                gui.set_theme(theme)
1192            gui.focus()
1193            return gui
1194
1195        try:
1196            from .PulsePalGUI import PulsePalGUI
1197        except ImportError:
1198            from PulsePalGUI import PulsePalGUI
1199
1200        gui = PulsePalGUI(self, theme=theme)
1201        self._gui = gui
1202        gui.start(block=block)
1203        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):
1205    def close(self, send_disconnect=True):
1206        """Close the connection to the device, and the GUI if open.
1207
1208        Safe to call more than once; later calls do nothing. Called
1209        automatically when leaving a `with` block and when the object is
1210        garbage collected.
1211
1212        Args:
1213            send_disconnect: If `True`, tell the device that the client
1214                is disconnecting before closing the port. Set to `False`
1215                when the device is in an unknown state, such as after a
1216                failed handshake.
1217        """
1218        gui = getattr(self, "_gui", None)
1219        self._gui = None
1220        if gui is not None:
1221            try:
1222                gui.close()
1223            except Exception:
1224                # Cleanup must not raise; Tk may already be torn down
1225                pass
1226
1227        if getattr(self, "_closed", True):
1228            return
1229
1230        try:
1231            if send_disconnect and self.port and self.port.is_open:
1232                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1233        finally:
1234            if self.port and self.port.is_open:
1235                self.port.close()
1236            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):
1238    def bytes_available(self):
1239        """Return the number of bytes waiting in the serial read buffer.
1240
1241        Returns:
1242            Count of bytes that can be read without blocking.
1243        """
1244        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:
100@dataclass
101class DeviceInfo:
102    """Properties of the connected Pulse Pal device.
103
104    An instance is created for each connection and populated during the
105    handshake in `PulsePalDevice.__init__`. It is available as
106    `PulsePalDevice.info`. Devices running firmware v21 report only a
107    firmware version, so the remaining fields are filled in with the
108    known values for Pulse Pal hardware v2.
109
110    ```python
111    print(P.info.firmware_version)
112    ```
113    """
114
115    output_parameter_names: list = None
116    """Output parameter names, ordered by parameter code.
117
118    The position of a name in this list, plus 1, is the parameter code
119    the device expects. Any name here is valid as the `param_name`
120    argument of `PulsePalDevice.set_output_param`, and is also the name
121    of the matching parameter array attribute on `PulsePalDevice`.
122    """
123
124    trigger_parameter_names: list = None
125    """Trigger parameter names, accepted by
126    `PulsePalDevice.set_trigger_param`."""
127
128    firmware_version: int = None
129    """Firmware version running on the connected device."""
130
131    hardware_version: int = None
132    """Hardware revision of the connected device, e.g. `2` or `3`.
133
134    Reported by the device on firmware v22 and newer; assumed to be `2`
135    on older firmware.
136    """
137
138    max_custom_pulses: int = None
139    """Maximum number of pulses in a single custom pulse train."""
140
141    n_custom_pulse_trains: int = None
142    """Number of custom pulse trains the device can store."""
143
144    cycle_frequency: float = None
145    """Update frequency of the device's hardware timer, in Hz.
146
147    All time parameters are rounded to a whole number of these cycles,
148    so this sets the timing resolution of the device.
149    """
150
151    cycle_period_us: float = None
152    """Update period of the device's hardware timer, in microseconds."""

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

class PulsePalError(builtins.Exception):
90class PulsePalError(Exception):
91    """Raised when Pulse Pal communication or configuration fails.
92
93    This covers serial reads that time out, short serial writes, missing
94    acknowledgement bytes, unknown parameter names, values that do not
95    fit the datatype expected by the device, and operations that the
96    connected firmware or hardware revision does not support.
97    """

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.