pulsepal.pulse_pal

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, set parameters, and trigger, e.g.

from pulsepal import PulsePalDevice

with PulsePalDevice("COM3") as P:
    P.phase1_voltage[1] = 5            # volts, output channel 1
    P.phase1_duration[1] = 0.001       # seconds: 1 ms pulses,
    P.inter_pulse_interval[1] = 0.049  # 49 ms apart (end of one to start of the next),
    P.pulse_train_duration[1] = 2      # for 2 seconds
    P.trigger(1)

Parameters

Each parameter is a list on the device object, indexed by channel number: index 0 is unused and holds None, and indices 1 to 4 hold the values for output channels 1-4. PulsePalDevice.trigger_mode is indexed the same way by trigger channel, 1 or 2. Assigning an element, a slice or the whole list programs the device at once, on the channels whose value it changes: the others keep what the device holds, which may have been set with its joystick. The whole list takes one value per channel: a single value raises an error, because it does not say which channels it is meant for.

P.phase1_voltage[2] = 7                  # channel 2 only
P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
P.phase1_voltage = [5, 5, 5, 5]          # all four channels
P.trigger_mode[2] = "Toggle"             # trigger channel 2

To change several parameters in one command, assign them in a PulsePalDevice.batch block: they are sent together when it ends.

with P.batch():
    P.is_biphasic[1] = True
    P.phase2_voltage[1] = -5
    P.inter_phase_interval[1] = 0.0005

PulsePalDevice.auto_sync set to False does the same for as long as it stays off, until PulsePalDevice.sync_to_device is called; turning it back on before then raises an error. That is how the next trial's parameters wait for a TTL in param sync mode (see PulsePalDevice.trigger_mode).

Assigning an attribute that is not a setting, such as a misspelled one or the MATLAB class's phase1Voltage, raises AttributeError.

Units and values

Voltages are in volts in the range [-10, 10]. Times are in seconds, from 0 to MAX_TIME (9999.9999 s), and are rounded to the nearest cycle of the device's hardware timer (DeviceInfo.cycle_period_us): 10 us on Pulse Pal 3, and 50 us on Pulse Pal 2 and on Pulse Pal 3 with firmware from before October 2026. The parameter then holds the time the device plays, so 0.000123 reads back as 0.00012 (0.0001 on a 50 us timer). A time exactly halfway between two cycles rounds to the even one, as in the MATLAB and C++ classes, and voltages round to the nearest DAC code the same way. On/off parameters are True or False, and modes are names, such as "Gated"; names are not case sensitive, and the integer codes of older Pulse Pal software are accepted too.

A value the device cannot play raises pulsepal.PulsePalError before anything is sent: a voltage outside [-10, 10], a time that is negative, longer than MAX_TIME or not a number, or an unknown name. So does a pulse phase, inter-pulse interval or pulse train duration shorter than DeviceInfo.min_pulse_width_us (10 us on Pulse Pal 3, 100 us on Pulse Pal 2), so that a Pulse Pal's trigger channels can detect the shortest pulse its output channels play. Custom pulse times are not rounded: each must be a multiple of DeviceInfo.min_pulse_width_us.

Saving parameters

PulsePalDevice.export_params returns every parameter as a dict, to store with your data or in a file, and PulsePalDevice.import_params programs the device with one. The device's microSD card holds programs too, which its joystick menu can load: PulsePalDevice.save_program_to_microsd.

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, set parameters, and trigger, e.g.
  12
  13```python
  14from pulsepal import PulsePalDevice
  15
  16with PulsePalDevice("COM3") as P:
  17    P.phase1_voltage[1] = 5            # volts, output channel 1
  18    P.phase1_duration[1] = 0.001       # seconds: 1 ms pulses,
  19    P.inter_pulse_interval[1] = 0.049  # 49 ms apart (end of one to start of the next),
  20    P.pulse_train_duration[1] = 2      # for 2 seconds
  21    P.trigger(1)
  22```
  23
  24## Parameters
  25
  26Each parameter is a list on the device object, indexed by channel
  27number: index 0 is unused and holds `None`, and indices 1 to 4 hold the
  28values for output channels 1-4. `PulsePalDevice.trigger_mode` is indexed
  29the same way by trigger channel, 1 or 2. Assigning an element, a slice
  30or the whole list programs the device at once, on the channels whose
  31value it changes: the others keep what the device holds, which may have
  32been set with its joystick. The whole list takes one value per channel:
  33a single value raises an error, because it does not say which channels it
  34is meant for.
  35
  36```python
  37P.phase1_voltage[2] = 7                  # channel 2 only
  38P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
  39P.phase1_voltage = [5, 5, 5, 5]          # all four channels
  40P.trigger_mode[2] = "Toggle"             # trigger channel 2
  41```
  42
  43To change several parameters in one command, assign them in a
  44`PulsePalDevice.batch` block: they are sent together when it ends.
  45
  46```python
  47with P.batch():
  48    P.is_biphasic[1] = True
  49    P.phase2_voltage[1] = -5
  50    P.inter_phase_interval[1] = 0.0005
  51```
  52
  53`PulsePalDevice.auto_sync` set to `False` does the same for as long as
  54it stays off, until `PulsePalDevice.sync_to_device` is called; turning
  55it back on before then raises an error. That is how the next trial's
  56parameters wait for a TTL in param sync mode (see
  57`PulsePalDevice.trigger_mode`).
  58
  59Assigning an attribute that is not a setting, such as a misspelled one
  60or the MATLAB class's `phase1Voltage`, raises `AttributeError`.
  61
  62## Units and values
  63
  64Voltages are in volts in the range [-10, 10]. Times are in seconds, from
  650 to `MAX_TIME` (9999.9999 s), and are rounded to the nearest cycle of
  66the device's hardware timer (`DeviceInfo.cycle_period_us`): 10 us on
  67Pulse Pal 3, and 50 us on Pulse Pal 2 and on Pulse Pal 3 with firmware
  68from before October 2026. The parameter then holds the time the device
  69plays, so 0.000123 reads back as 0.00012 (0.0001 on a 50 us timer). A
  70time exactly halfway between two cycles rounds to the
  71even one, as in the MATLAB and C++ classes, and voltages round to the
  72nearest DAC code the same way. On/off parameters are `True` or `False`,
  73and modes are names, such as `"Gated"`; names are not case sensitive,
  74and the integer codes of older Pulse Pal software are accepted too.
  75
  76A value the device cannot play raises `pulsepal.PulsePalError` before
  77anything is sent: a voltage outside [-10, 10], a time that is negative,
  78longer than `MAX_TIME` or not a number, or an unknown name. So does a
  79pulse phase, inter-pulse interval or pulse train duration shorter than
  80`DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 100 us on Pulse
  81Pal 2), so that a Pulse Pal's trigger channels can detect the shortest
  82pulse its output channels play. Custom pulse times are not rounded: each
  83must be a multiple of `DeviceInfo.min_pulse_width_us`.
  84
  85## Saving parameters
  86
  87`PulsePalDevice.export_params` returns every parameter as a dict, to
  88store with your data or in a file, and `PulsePalDevice.import_params`
  89programs the device with one. The device's microSD card holds programs
  90too, which its joystick menu can load:
  91`PulsePalDevice.save_program_to_microsd`.
  92
  93## Further reading
  94
  95- Parameter guide:
  96  https://sites.google.com/site/pulsepalwiki/parameter-guide
  97- Serial interface and general documentation:
  98  https://sites.google.com/site/pulsepalwiki/
  99- `pulsepal.wave_pal`: alternative firmware that makes a Pulse Pal 3 a
 100  four channel waveform player, and its class, `pulsepal.WavePalDevice`.
 101- `pulsepal.synth_pal`: alternative firmware that makes a Pulse Pal 3 a
 102  four channel waveform synthesizer, and its class,
 103  `pulsepal.SynthPalDevice`.
 104
 105## License
 106
 107This file is part of the Sanworks PulsePal repository.
 108Copyright (C) Sanworks LLC, Rochester, New York, USA
 109
 110This program is free software: you can redistribute it and/or modify
 111it under the terms of the GNU General Public License as published by
 112the Free Software Foundation, version 3.
 113
 114This program is distributed WITHOUT ANY WARRANTY and without even the
 115implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 116See the GNU General Public License for more details.
 117
 118You should have received a copy of the GNU General Public License
 119along with this program. If not, see <http://www.gnu.org/licenses/>.
 120"""
 121
 122import collections.abc
 123from dataclasses import dataclass
 124import math
 125import numbers
 126import struct
 127import time
 128import warnings
 129
 130import numpy as np
 131import serial
 132
 133from . import _common
 134from ._common import ChannelSettings, PulsePalError, to_bool, to_name
 135
 136__all__ = ["PulsePalDevice", "DeviceInfo", "DeviceStatus", "TRIGGER_MODES", "CUSTOM_TRAIN_TARGETS", "MAX_TIME"]
 137__docformat__ = "google"
 138
 139MAX_TIME = 9999.9999
 140"""The longest time parameter or custom pulse time, in seconds: the most
 141the device's joystick menu shows and edits (four digits before the
 142point)."""
 143
 144TRIGGER_MODES = ("Normal", "Toggle", "Gated", "Param Sync")
 145"""Names of the trigger modes, in order of their code on the device (0-3).
 146"Param Sync" is on Pulse Pal 3 only. See `PulsePalDevice.trigger_mode`."""
 147
 148CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts")
 149"""Names of the custom train targets, in order of their code on the device.
 150See `PulsePalDevice.custom_train_target`."""
 151
 152# Output parameters by parameter code (see "Parameter codes" in /Firmware/PROTOCOL.md), with
 153# the kind of value each takes. The codes are fixed: installed clients and the firmware use them.
 154# "pulse_time" is a time of at least DeviceInfo.min_pulse_width_us.
 155_OUTPUT_PARAMETERS = {
 156    1: ("is_biphasic", "bool"),
 157    2: ("phase1_voltage", "volts"),
 158    3: ("phase2_voltage", "volts"),
 159    4: ("phase1_duration", "pulse_time"),
 160    5: ("inter_phase_interval", "time"),
 161    6: ("phase2_duration", "pulse_time"),
 162    7: ("inter_pulse_interval", "pulse_time"),
 163    8: ("burst_duration", "time"),
 164    9: ("inter_burst_interval", "time"),
 165    10: ("pulse_train_duration", "pulse_time"),
 166    11: ("pulse_train_delay", "time"),
 167    12: ("link_trigger_channel1", "bool"),
 168    13: ("link_trigger_channel2", "bool"),
 169    14: ("custom_train_id", "train_id"),
 170    15: ("custom_train_target", "target"),
 171    16: ("custom_train_loop", "bool"),
 172    17: ("resting_voltage", "volts"),
 173    18: ("continuous_loop", "bool"),
 174}
 175_OUTPUT_PARAMETER_CODES = {name: code for code, (name, _) in _OUTPUT_PARAMETERS.items()}
 176_TRIGGER_MODE_CODE = 128  # The one trigger channel parameter
 177_CONTINUOUS_LOOP_CODE = 18  # Not in ops 73 and 93, or settings files
 178_BURST_DURATION_CODE = 8  # These two are checked together: see PulsePalDevice._check_custom_bursts()
 179_CUSTOM_TRAIN_TARGET_CODE = 15
 180_FORMAT_KEY = b"WIPE"  # Follows op 97, so that two stray bytes cannot format the card (FORMAT_SD_CARD_KEY in the firmware)
 181# The ops whose reply _write_raw() does not track: those with no reply, and the handshakes (72 and 103), whose reply
 182# read_handshake_reply() reads until the device is quiet
 183_OPS_WITHOUT_TRACKED_REPLY = frozenset({72, 77, 80, 81, 89, 98, 103})
 184# The ops that can change the parameters: if the reply to one is lost, they are read back (_discard_lost_reply())
 185_PARAMETER_OPS = frozenset({73, 74, 90, 91, 92, 97})
 186# The names a script assigns: the output parameters, in parameter code order, then these
 187_SETTING_NAMES = (*(name for name, _ in _OUTPUT_PARAMETERS.values()), "trigger_mode", "auto_sync")
 188
 189# The parameters that ops 73, 92 and 93 carry in each block, in order
 190_TIME_PARAMETERS = ("phase1_duration", "inter_phase_interval", "phase2_duration", "inter_pulse_interval",
 191                    "burst_duration", "inter_burst_interval", "pulse_train_duration", "pulse_train_delay")
 192_VOLTAGE_PARAMETERS = ("phase1_voltage", "phase2_voltage", "resting_voltage")
 193
 194# The default parameters: the device's own (LoadDefaultParameters() in /Firmware/PulsePal/SDSettings.ino), which it
 195# loads at startup, after a comm failure and after formatting its microSD card, so that it plays the same program
 196# whichever way it got its defaults. The MATLAB class has the same; the legacy clients keep their own.
 197_DEFAULT_OUTPUT_VALUES = {
 198    "is_biphasic": False,
 199    "phase1_voltage": 5.0,
 200    "phase2_voltage": -5.0,
 201    "phase1_duration": 0.0001,
 202    "inter_phase_interval": 0.0001,
 203    "phase2_duration": 0.0001,
 204    "inter_pulse_interval": 0.001,
 205    "burst_duration": 0.0,
 206    "inter_burst_interval": 0.0,
 207    "pulse_train_duration": 1.0,
 208    "pulse_train_delay": 0.0,
 209    "link_trigger_channel1": False,  # Until 2026-10 True, so a TTL played these pulses on every output channel
 210    "link_trigger_channel2": False,
 211    "custom_train_id": 0,
 212    "custom_train_target": "Pulses",
 213    "custom_train_loop": False,
 214    "resting_voltage": 0.0,
 215    "continuous_loop": False,
 216}
 217
 218
 219@dataclass
 220class DeviceInfo:
 221    """Properties of the connected Pulse Pal device.
 222
 223    An instance is created for each connection and populated during the
 224    handshake in `PulsePalDevice.__init__`. It is available as
 225    `PulsePalDevice.info`. Devices running firmware v21 report only a
 226    firmware version, so the remaining fields are filled in with the
 227    known values for Pulse Pal hardware v2.
 228
 229    ```python
 230    print(P.info.firmware_version)
 231    ```
 232    """
 233
 234    output_parameter_names: tuple = tuple(name for name, _ in _OUTPUT_PARAMETERS.values())
 235    """Output parameter names, ordered by parameter code.
 236
 237    Each is the name of a parameter list on `PulsePalDevice`, and a key of
 238    the dict `PulsePalDevice.export_params` returns.
 239    """
 240
 241    trigger_modes: tuple = TRIGGER_MODES
 242    """Names of the trigger modes the connected device has, accepted by
 243    `PulsePalDevice.trigger_mode`. Pulse Pal 2 has no "Param Sync"."""
 244
 245    custom_train_targets: tuple = CUSTOM_TRAIN_TARGETS
 246    """Names accepted by `PulsePalDevice.custom_train_target`."""
 247
 248    firmware_version: int = None
 249    """Firmware version running on the connected device."""
 250
 251    hardware_version: int = None
 252    """Hardware version of the connected device, e.g. `2` or `3`.
 253
 254    Reported by the device on firmware v22 and newer; assumed to be `2`
 255    on older firmware.
 256    """
 257
 258    circuit_board_revision: int = None
 259    """Revision of the device's circuit board, 0-31.
 260
 261    A Pulse Pal 3 board encodes its revision on five pins, which the
 262    firmware reads at startup. A board that encodes none reports `0`, as
 263    does Pulse Pal 2; firmware older than v22 cannot report one, so it is
 264    also `0` there. To record exactly which hardware ran an experiment,
 265    copy `hardware_version` and `circuit_board_revision` to your data.
 266    """
 267
 268    serial_number: str = None
 269    """USB serial number of the device, e.g. `"20345420"`: the Teensy's,
 270    unique to each Pulse Pal 3, and the same whichever firmware it runs.
 271
 272    Connect by it, wherever the device is plugged in, with
 273    `PulsePalDevice(serial_number="20345420")`. To record exactly which unit ran an
 274    experiment, copy it to your data. Pulse Pal 2 has none, and
 275    firmware older than v22 cannot report one: `""`.
 276    """
 277
 278    max_custom_pulses: int = None
 279    """Maximum number of pulses in a single custom pulse train."""
 280
 281    n_custom_pulse_trains: int = None
 282    """Number of custom pulse trains the device can store."""
 283
 284    cycle_frequency: float = None
 285    """Update frequency of the device's hardware timer, in Hz.
 286
 287    All time parameters are rounded to a whole number of these cycles,
 288    so this sets the timing resolution of the device.
 289    """
 290
 291    cycle_period_us: float = None
 292    """Update period of the device's hardware timer, in microseconds."""
 293
 294    min_pulse_width_us: float = None
 295    """Shortest pulse phase, inter-pulse interval and pulse train duration,
 296    and shortest time between custom pulses, in microseconds.
 297
 298    One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch
 299    every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose
 300    trigger channels read their inputs once per cycle, so that a one cycle
 301    pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3
 302    with firmware from before October 2026, can miss a pulse shorter than
 303    100 us from a Pulse Pal 3. Custom pulse times are multiples of it.
 304    """
 305
 306    max_time: float = MAX_TIME
 307    """Longest time parameter or custom pulse time, in seconds. See
 308    `MAX_TIME`."""
 309
 310
 311@dataclass
 312class DeviceStatus:
 313    """What the device is doing, and what has happened to it, from
 314    `PulsePalDevice.status`.
 315
 316    The counters count from the previous call to `PulsePalDevice.status`,
 317    by this or any earlier connection, or from the device's startup: each
 318    call clears them, and reports what happened since the one before.
 319    """
 320
 321    playing: list
 322    """Numbers of the output channels playing, e.g. `[1, 3]`: from a
 323    channel's start, its pulse train delay included, until its train
 324    ends."""
 325
 326    trigger_edges: list
 327    """Rising edges on each trigger channel, indexed by trigger channel
 328    number (index 0 is unused), whatever its trigger mode and whether or
 329    not they started an output channel."""
 330
 331    channel_starts: list
 332    """Times each output channel started, indexed by channel number
 333    (index 0 is unused): by a trigger channel, `PulsePalDevice.trigger`
 334    or the joystick menu. A trigger that a channel ignored, because it
 335    was already playing, is not a start."""
 336
 337    comm_failures: int
 338    """Commands that reached the device only in part, after which it
 339    showed COMM. FAILURE! and waited for its joystick to be clicked."""
 340
 341
 342def _params_to_import(params):
 343    """The parameters import_params() programs, as {name: values} by this class's names.
 344
 345    `params` is what export_params() returns, or a whole program file as json.load() reads it, whose "params" are
 346    taken; the MATLAB class's names are accepted too ("phase1Voltage"), so that its exportParams() can be imported.
 347    Names that are not parameters are skipped with a warning, and if none is, PulsePalError is raised: the device
 348    would otherwise be sent its program unchanged, with only a warning to say why.
 349    """
 350    if not isinstance(params, collections.abc.Mapping):
 351        raise PulsePalError(
 352            f"import_params() takes a dict of parameters, as export_params() returns. Received {type(params).__name__}."
 353        )
 354    if isinstance(params.get("params"), collections.abc.Mapping):
 355        # A program file, as the GUIs and the MATLAB class's saveProgramToFile() save it: format_version, params, the
 356        # text of each custom train, device_info (see "Program files" in /USING.md)
 357        program = params
 358        params = dict(program["params"])
 359        if "trigger_mode" not in params and "trigger_mode" in program:
 360            params["trigger_mode"] = program["trigger_mode"]  # Files from before format_version held it here
 361        if any(program.get("custom_train_timestamps") or []) or any(program.get("custom_train_voltages") or []):
 362            _common.warn("import_params() imports a program file's parameters only: open it with gui() to load its "
 363                         "custom trains too.")
 364    keys = {}  # This class's name: the key in params
 365    unknown = []
 366    for key in params:
 367        name = _common.snake_case(key) if isinstance(key, str) else None
 368        if name not in _SETTING_NAMES or name == "auto_sync":
 369            unknown.append(key)
 370        elif name in keys:
 371            raise PulsePalError(f"import_params(): {keys[name]!r} and {key!r} are the same parameter.")
 372        else:
 373            keys[name] = key
 374    if not keys:
 375        listed = ", ".join(map(repr, unknown)) or "nothing"
 376        raise PulsePalError(
 377            f"import_params() found no parameter in {listed}, so it programs nothing. It takes what export_params() "
 378            "returns, e.g. {'phase1_voltage': [5, 5, 5, 5]}, or a whole program file (json.load() of one)."
 379        )
 380    if unknown:
 381        _common.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of PulsePalDevice "
 382                     "does not have.")
 383    return {name: params[key] for name, key in keys.items()}
 384
 385
 386def _channel_setting(name, doc):
 387    """A property for one of PulsePalDevice's parameter lists."""
 388
 389    def get(self):
 390        return getattr(self, "_" + name)
 391
 392    def set_(self, values):
 393        getattr(self, "_" + name)._assign(values)
 394
 395    return property(get, set_, doc=doc)
 396
 397
 398class PulsePalDevice:
 399    """A class to control a Pulse Pal device on a USB serial port.
 400
 401    Creating an instance opens the serial port, exchanges a handshake
 402    with the device, verifies that its firmware is supported, reads the
 403    device properties into `PulsePalDevice.info`, and programs the
 404    device with the default parameters (see
 405    `PulsePalDevice.set_default_params`).
 406
 407    ```python
 408    from pulsepal import PulsePalDevice
 409
 410    P = PulsePalDevice("COM3")
 411    P.phase1_voltage[1] = 5
 412    P.trigger(1)
 413    P.close()
 414    ```
 415
 416    Here, replace "COM3" with Pulse Pal's USB serial port name.
 417    To view a list of available ports, use
 418    `PulsePalDevice.serialportlist()`.
 419    Pulse Pal's port may not be visible if it is connected to another
 420    instance of PulsePalDevice or an external application. Use
 421    `PulsePalDevice.serialportlist("all")` to view all ports.
 422    If you see multiple available ports, disconnect Pulse Pal's USB plug
 423    and run serialportlist() again. Notice which port disappears from the
 424    list.
 425
 426    PulsePalDevice is also a context manager, which closes the connection on
 427    exit even if an error is raised:
 428
 429    ```python
 430    with PulsePalDevice("COM3") as P:
 431        P.trigger(1)
 432    ```
 433
 434    The attributes below named after Pulse Pal parameters are lists
 435    indexed by channel number, with index 0 unused (see "Parameters"
 436    above). `print(P)` shows them all, and `PulsePalDevice.export_params`
 437    returns them as a dict.
 438    """
 439
 440    port: "serial.Serial"
 441    """The open `serial.Serial` port connected to the device."""
 442
 443    info: DeviceInfo
 444    """Properties of the connected device. See `DeviceInfo`."""
 445
 446    is_biphasic = _channel_setting("is_biphasic", """Pulse shape per channel: `False` for monophasic, `True` for biphasic.
 447
 448    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 449    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 450    phase 2.
 451    """)
 452
 453    phase1_voltage = _channel_setting("phase1_voltage", """Voltage of the first phase of each pulse, in volts [-10, 10].""")
 454
 455    phase2_voltage = _channel_setting("phase2_voltage", """Voltage of the second phase of each pulse, in volts [-10, 10].
 456
 457    Used only when `PulsePalDevice.is_biphasic` is `True` for the channel.
 458    """)
 459
 460    resting_voltage = _channel_setting("resting_voltage", """Voltage held between pulses, in volts [-10, 10].
 461
 462    A new resting voltage reaches an idle channel's output at once. A
 463    channel playing a pulse train keeps playing it, and moves to the new
 464    resting voltage at its next transition to rest.
 465    """)
 466
 467    phase1_duration = _channel_setting("phase1_duration", """Duration of the first phase of each pulse, in seconds.
 468
 469    At least `DeviceInfo.min_pulse_width_us`.
 470    """)
 471
 472    inter_phase_interval = _channel_setting("inter_phase_interval", """Interval between the two phases of a biphasic pulse, in seconds.
 473
 474    The channel rests at `PulsePalDevice.resting_voltage` during the
 475    interval. Used only when `PulsePalDevice.is_biphasic` is `True`.
 476    """)
 477
 478    phase2_duration = _channel_setting("phase2_duration", """Duration of the second phase of each pulse, in seconds.
 479
 480    Used only when `PulsePalDevice.is_biphasic` is `True` for the channel.
 481    At least `DeviceInfo.min_pulse_width_us`.
 482    """)
 483
 484    inter_pulse_interval = _channel_setting("inter_pulse_interval", """Interval from the end of one pulse to the start of the next, in
 485    seconds. At least `DeviceInfo.min_pulse_width_us`.
 486
 487    It is not the pulse period: 1 ms pulses 49 ms apart play 20 pulses per
 488    second.
 489    """)
 490
 491    burst_duration = _channel_setting("burst_duration", """Duration of each burst of pulses, in seconds.
 492
 493    Set to `0` to disable bursts, so that pulses continue for the whole
 494    pulse train. A pulse starts only if it ends before the burst does:
 495    its first phase, or for a biphasic pulse the whole pulse, so that
 496    the end of a burst never cuts off a second phase.
 497    """)
 498
 499    inter_burst_interval = _channel_setting("inter_burst_interval", """Interval between bursts of pulses, in seconds.
 500
 501    The channel rests at `PulsePalDevice.resting_voltage` between
 502    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 503    """)
 504
 505    pulse_train_duration = _channel_setting("pulse_train_duration", """Total duration of the pulse train, in seconds.
 506
 507    At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts
 508    short a monophasic pulse still playing. A biphasic pulse starts only
 509    if it can end by the end of the train, so that it keeps its second
 510    phase. A train's end is set when it starts: a new duration applies
 511    from the next train.
 512    """)
 513
 514    pulse_train_delay = _channel_setting("pulse_train_delay", """Delay from the trigger to the onset of the pulse train, in
 515    seconds.""")
 516
 517    link_trigger_channel1 = _channel_setting("link_trigger_channel1", """Whether each output channel is triggered by trigger channel 1.
 518
 519    `True` links the output channel to trigger channel 1, `False` unlinks
 520    it. By default no output channel is linked to either trigger channel,
 521    so a TTL starts nothing until a script links it.
 522    """)
 523
 524    link_trigger_channel2 = _channel_setting("link_trigger_channel2", """Whether each output channel is triggered by trigger channel 2.
 525
 526    `True` links the output channel to trigger channel 2, `False` unlinks
 527    it.
 528    """)
 529
 530    custom_train_id = _channel_setting("custom_train_id", """Custom pulse train played by each output channel.
 531
 532    `0` plays the parametrically defined train. `1` or higher plays the
 533    matching custom train, previously loaded with
 534    `PulsePalDevice.send_custom_pulse_train` or
 535    `PulsePalDevice.send_custom_waveform`: 1-4 on Pulse Pal 3, 1-2 on
 536    Pulse Pal 2 (`DeviceInfo.n_custom_pulse_trains`).
 537    """)
 538
 539    custom_train_target = _channel_setting("custom_train_target", """What the times of a custom train mark.
 540
 541    `"Pulses"` if each time is the onset of a pulse, `"Bursts"` if each
 542    time is the onset of a burst of pulses, which lasts
 543    `PulsePalDevice.burst_duration`. A burst whose time comes while the
 544    burst before plays is dropped, so that every burst played is on
 545    time. `"Bursts"` needs a burst duration above 0: with
 546    `PulsePalDevice.auto_sync` on, set the burst duration first, or set
 547    both in a `PulsePalDevice.batch` block.
 548    """)
 549
 550    custom_train_loop = _channel_setting("custom_train_loop", """Whether a custom train repeats.
 551
 552    `True` loops the custom train until
 553    `PulsePalDevice.pulse_train_duration` has elapsed, `False` plays it
 554    once. Each pass starts as the last pulse (or burst, for a train
 555    targeting bursts) of the pass before ends.
 556    `PulsePalDevice.continuous_loop` does not apply to custom trains.
 557    """)
 558
 559    continuous_loop = _channel_setting("continuous_loop", """Whether each output channel plays its pulse train until stopped.
 560
 561    `False` plays the pulse train once per trigger, for
 562    `PulsePalDevice.pulse_train_duration`. `True` plays it until the
 563    channel is stopped, ignoring `PulsePalDevice.pulse_train_duration`.
 564    For parametric trains only: a custom train ignores it, and plays as
 565    `PulsePalDevice.custom_train_loop` says. Requires firmware v22 or
 566    newer. The device does not report it, so
 567    `PulsePalDevice.sync_from_device` leaves it as it is.
 568    """)
 569
 570    trigger_mode = _channel_setting("trigger_mode", """Response of each trigger channel to an incoming TTL pulse.
 571
 572    Indexed by trigger channel number, 1 or 2 (index 0 is unused). The
 573    modes, also listed in `DeviceInfo.trigger_modes`, are:
 574
 575    - `"Normal"`: a rising edge starts the pulse trains of the linked
 576      output channels, and edges during a train are ignored.
 577    - `"Toggle"`: as `"Normal"`, but a rising edge during a train stops
 578      it.
 579    - `"Gated"`: the trains play only while the trigger TTL is high.
 580    - `"Param Sync"` (Pulse Pal 3 only): a TTL rising edge starts and
 581      stops nothing. It loads the parameters most recently sent by
 582      `PulsePalDevice.sync_to_device` (or at the end of a
 583      `PulsePalDevice.batch` block), and the custom trains loaded since
 584      the previous edge. This is how the next trial's parameters and
 585      custom trains are sent during the current trial and applied the
 586      instant it starts.
 587
 588    The integer codes 0-3 are accepted too.
 589
 590    An output channel that is idle at a param sync edge takes its new
 591    parameters in the timer cycle the edge is detected. One that is
 592    playing a pulse train finishes that train on the parameters it
 593    started with, and takes the new ones the moment it ends, so a train
 594    that runs past the end of a trial keeps one shape throughout and the
 595    next trigger plays a whole train with the new parameters. A channel
 596    in continuous loop mode has no train end, so it keeps its parameters
 597    until something stops it.
 598
 599    While either trigger channel is in param sync mode, **only
 600    `PulsePalDevice.sync_to_device` (and the end of a
 601    `PulsePalDevice.batch` block) and the custom train loads are held
 602    back**: with `PulsePalDevice.auto_sync` on, every assignment still
 603    programs the device immediately. So leaving param sync mode means
 604    assigning `trigger_mode` with `auto_sync` on; a trigger mode sent by
 605    `PulsePalDevice.sync_to_device` does not take effect until a sync
 606    edge arrives. A custom train loaded in param sync mode
 607    (`PulsePalDevice.send_custom_pulse_train`) replaces the old one at
 608    the edge: channels playing the old one at the edge finish their
 609    trains on it. Leaving param sync mode discards parameters still
 610    waiting for an edge, but makes a custom train still waiting current
 611    at once.
 612
 613    A param sync channel's links to output channels are ignored. To
 614    start a pulse train on the same edge, wire the TTL to the other
 615    trigger channel as well: both edges arrive in the same timer cycle,
 616    and the parameters are loaded first.
 617
 618    Connecting a new `PulsePalDevice`, and
 619    `PulsePalDevice.set_default_params`, take both trigger channels out
 620    of param sync mode, so that the default parameters reach the device
 621    instead of waiting for a TTL.
 622
 623    ```python
 624    P.trigger_mode[2] = "Param Sync"   # sent at once
 625    with P.batch():
 626        P.phase1_voltage[1] = 2.5
 627        P.phase1_duration[1] = 0.002
 628    # Stored by the device: the next rising edge on trigger channel 2 applies it
 629    ```
 630    """)
 631
 632    _CURRENT_FIRMWARE_VERSION = 22
 633    _OLDEST_FIRMWARE_SUPPORTED = 21
 634    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 635
 636    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 637    _HANDSHAKE_OPCODE = _common.OP_HANDSHAKE
 638    _HANDSHAKE_DEVICE_CYCLES_OPCODE = _common.OP_HANDSHAKE_DEVICE_CYCLES
 639    _HANDSHAKE_DEVICE_CYCLES_TIMEOUT = 0.3  # Seconds to wait for a reply to op 103 before trying op 72
 640    _HANDSHAKE_RESPONSE = 75  # 'K'
 641    _DAC_BITMAX = 65535
 642    _PARAM_MESSAGE_BYTES = 178  # Length of the parameter set sent by op 93
 643    _STATUS_MESSAGE_BYTES = 29  # Length of the reply to op 101: playing channels, then 7 uint32 counters
 644    # Phase 1 and 2 durations, inter-pulse interval and train duration last at least this many
 645    # timer cycles, by hardware version, as in the MATLAB and C++ classes and the joystick menu.
 646    # Pulse Pal 2's trigger channels read their inputs once per cycle, so a one cycle pulse from
 647    # another Pulse Pal could fall between two reads. Pulse Pal 3's also latch every edge.
 648    _MIN_PULSE_CYCLES = {2: 2, 3: 1}
 649
 650    _ENDIANNESS = "<"
 651    _STRUCT_FORMATS = {
 652        "uint8": "B",
 653        "int8": "b",
 654        "uint16": "H",
 655        "int16": "h",
 656        "uint32": "I",
 657        "int32": "i",
 658    }
 659    _TYPE_RANGES = {
 660        "uint8": (0, 2**8 - 1),
 661        "int8": (-(2**7), 2**7 - 1),
 662        "uint16": (0, 2**16 - 1),
 663        "int16": (-(2**15), 2**15 - 1),
 664        "uint32": (0, 2**32 - 1),
 665        "int32": (-(2**31), 2**31 - 1),
 666    }
 667
 668    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_raw())
 669    _unsent = frozenset()  # The settings assigned with auto_sync off since the parameters were last sent or read
 670
 671    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 672        """Open a connection to a Pulse Pal device.
 673
 674        Opens the serial port, exchanges the handshake, verifies the
 675        firmware version, reads the device properties into
 676        `PulsePalDevice.info`, and programs the device with the default
 677        parameters.
 678
 679        ```python
 680        P = PulsePalDevice("COM3")
 681        P = PulsePalDevice(serial_number="20345420")  # Wherever it is plugged in
 682        ```
 683
 684        Args:
 685            port_name: USB serial port for the Pulse Pal device, such as
 686                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 687            serial_number: Instead of `port_name`, the device's USB serial
 688                number (`DeviceInfo.serial_number`), which finds its port.
 689                Pulse Pal 3 only: Pulse Pal 2 has none.
 690            timeout: Serial read timeout, in seconds.
 691
 692        Firmware newer than this module knows (`v22`) is used with a
 693        warning: new firmware only adds commands, so everything here still
 694        works with it. Update the package to use what is new.
 695
 696        Raises:
 697            PulsePalError: If the device does not return the expected
 698                handshake (for example, it runs Wave Pal or Synth Pal
 699                firmware), or its firmware is older than v21, or no port
 700                has the serial number given. The port is closed again
 701                before any error is raised.
 702            serial.SerialException: If the serial port cannot be opened,
 703                e.g. because another program has it open (on Linux and
 704                macOS, another session of these classes, which lock it).
 705        """
 706        self._closed = True
 707        self._gui = None
 708        self._auto_sync = True
 709        self.info = DeviceInfo()
 710        self._create_settings()
 711        port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice")
 712        self.port = serial.Serial(
 713            port_name,
 714            self._BAUD_RATE,
 715            timeout=timeout,
 716            rtscts=True,
 717            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 718            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 719            # it, such as another of these classes, gets an error instead
 720            exclusive=True,
 721        )
 722        self._closed = False
 723        try:
 724            self._start_session(port_name)
 725        except BaseException:
 726            # Otherwise the port stays open until the object is garbage
 727            # collected, and a second attempt, or WavePalDevice, cannot open
 728            # it. The device is in an unknown state, so it is not sent the
 729            # disconnect op.
 730            self._close(send_disconnect=False)
 731            raise
 732
 733    def _create_settings(self):
 734        """Create the parameter lists, holding the default values (nothing is sent)."""
 735        for code, (name, _) in _OUTPUT_PARAMETERS.items():
 736            setattr(self, "_" + name, ChannelSettings(
 737                name, [_DEFAULT_OUTPUT_VALUES[name]] * 4, self, f"_apply_{name}"))
 738        self._trigger_mode = ChannelSettings(
 739            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 740
 741    def _start_session(self, port_name):
 742        """Handshake, check the firmware, and program the defaults."""
 743        # Discard anything left unread by an earlier session (one stopped mid-reply, for example):
 744        # it would be read as the handshake's reply. A reply the device sends after this, to a
 745        # command it had not reached yet, is skipped by read_handshake_reply().
 746        self.port.reset_input_buffer()
 747        # Op 103: the session's times are in the device's own timer cycles (10 us on Pulse Pal 3). Firmware from before
 748        # October 2026, Wave Pal and Synth Pal ignore it, and answer op 72, whose times are in 50 us cycles. A device that
 749        # answers op 103 too late counts the session's times in 50 us cycles too, after the op 72 that follows; either
 750        # way op 94 reports the session's unit, which the class then uses.
 751        self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_DEVICE_CYCLES_OPCODE), "uint8")
 752        reply = _common.read_handshake_reply(self.port, timeout=self._HANDSHAKE_DEVICE_CYCLES_TIMEOUT)
 753        if reply is None:
 754            self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), "uint8")
 755            reply = _common.read_handshake_reply(self.port)
 756        if reply is None:
 757            raise PulsePalError(
 758                f"No reply to the handshake from the device on {port_name}. Is it a Pulse Pal? "
 759                f"{_common.NO_REPLY_HINT}"
 760            )
 761        handshake, firmware_version = reply
 762        if handshake != self._HANDSHAKE_RESPONSE:
 763            if handshake in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
 764                raise _common.other_firmware_error(handshake, firmware_version, port_name, "Pulse Pal")
 765            raise PulsePalError(
 766                "Incorrect handshake returned. Expected "
 767                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 768            )
 769
 770        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 771            raise PulsePalError(
 772                "Old firmware detected, v"
 773                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 774                "newer is required."
 775            )
 776        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 777            # New firmware only adds commands (see /Firmware/PROTOCOL.md), so this class still works with it
 778            warnings.warn(
 779                f"Pulse Pal firmware v{firmware_version} is newer than this pulsepal package knows "
 780                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what "
 781                "is new.", stacklevel=3)
 782        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 783            warnings.warn(
 784                f"Pulse Pal firmware v{firmware_version} is supported, but v"
 785                f"{self._CURRENT_FIRMWARE_VERSION} is available: see /MATLAB/FirmwareLoader.", stacklevel=3)
 786        self.info.firmware_version = firmware_version
 787
 788        if self.info.firmware_version > 21:
 789            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 790            self.info.hardware_version = self._read_serial(1, "uint8")
 791            self.info.cycle_period_us = self._read_serial(1, "uint32")  # The session's time unit (see op 103 above)
 792            # Not 1 / (period / 1e6): at 10 us that is 99999.99999999999, and times read back from the device would
 793            # convert to 0.00010000000000000002 s instead of 0.0001 s
 794            self.info.cycle_frequency = 1000000 / self.info.cycle_period_us
 795            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 796            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 797            self._write_serial((self._OP_MENU_BYTE, 100), "uint8")  # Circuit board revision
 798            revision = self.port.read(1)
 799            if len(revision) < 1:
 800                raise PulsePalError(
 801                    f"The Pulse Pal on {port_name} did not report its circuit board revision."
 802                )
 803            self._reply_pending = None  # Read without _read_raw() (see _write_raw())
 804            self.info.circuit_board_revision = revision[0]
 805            self._write_serial((self._OP_MENU_BYTE, _common.OP_SERIAL_NUMBER), "uint8")
 806            self.info.serial_number = _common.read_serial_number(self._read_raw)
 807        else:
 808            self.info.hardware_version = 2
 809            self.info.circuit_board_revision = 0
 810            self.info.serial_number = ""  # v21 runs on Pulse Pal 2, which has none
 811            self.info.cycle_period_us = 50
 812            self.info.cycle_frequency = 20000
 813            self.info.n_custom_pulse_trains = 2
 814            self.info.max_custom_pulses = 5000
 815        self.info.min_pulse_width_us = (
 816            self._MIN_PULSE_CYCLES[self.info.hardware_version] * self.info.cycle_period_us
 817        )
 818        if not self._has_param_sync():
 819            self.info.trigger_modes = TRIGGER_MODES[:3]
 820
 821        # Client name op + "PYTHON" in ASCII.
 822        self._write_serial(
 823            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 824            "uint8",
 825        )
 826
 827        self.set_default_params()
 828
 829    @staticmethod
 830    def serialportlist(ports_to_list="available"):
 831        """Return the names of the USB serial ports on this computer.
 832
 833        Called on the class, without connecting to a device, to find the
 834        port name to pass to `PulsePalDevice`:
 835
 836        ```python
 837        from pulsepal import PulsePalDevice
 838
 839        ports = PulsePalDevice.serialportlist()
 840        P = PulsePalDevice(ports[0])
 841        ```
 842
 843        Args:
 844            ports_to_list: `available` to list only the ports that are
 845                not already in use, or `all` to list every USB serial
 846                port. Not case sensitive.
 847
 848        Returns:
 849            Sorted list of port names, such as `["COM3", "COM7"]` on
 850            Windows or `["/dev/ttyACM0"]` on Linux.
 851
 852        Raises:
 853            PulsePalError: If `ports_to_list` is not `available` or
 854                `all`.
 855        """
 856        return _common.serialportlist(ports_to_list)
 857
 858    # ------------------------------------------------------------------
 859    # Programming the device
 860    # ------------------------------------------------------------------
 861
 862    @property
 863    def auto_sync(self):
 864        """Whether assigning a parameter programs the device at once.
 865
 866        `True` (the default): each assignment, such as
 867        `P.phase1_voltage[1] = 5`, programs the device at once, also while
 868        a trigger channel is in param sync mode. It sends the channels
 869        whose value it changes, and nothing for a channel that already
 870        holds the value assigned: the other channels keep what the device
 871        holds, e.g. values set with its joystick (read them with
 872        `PulsePalDevice.sync_from_device`).
 873
 874        `False`: assignments change only this object's copy of the
 875        parameters, checking each value, and
 876        `PulsePalDevice.sync_to_device` sends all of them in one command.
 877        Turning it back on does not send changes made meanwhile, so it
 878        raises `pulsepal.PulsePalError` while there are any: call
 879        `PulsePalDevice.sync_to_device` first to send them, or
 880        `PulsePalDevice.sync_from_device` to discard them. A
 881        `PulsePalDevice.batch` block does it all for you. `close()` warns of
 882        changes never sent.
 883        """
 884        return self._auto_sync
 885
 886    @auto_sync.setter
 887    def auto_sync(self, value):
 888        value = to_bool(value, "auto_sync")
 889        if value and self._unsent:
 890            # Turned on, auto_sync would leave them unsent, and the device playing its old parameters
 891            discard = "sync_from_device() to discard them"
 892            if "continuous_loop" in self._unsent:
 893                discard += " (all but continuous_loop, which the device does not report)"
 894            raise PulsePalError(
 895                f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call "
 896                f"sync_to_device() to send them, or {discard}, before turning auto_sync on."
 897            )
 898        self._auto_sync = value
 899
 900    def batch(self):
 901        """Change several parameters, and send them in one command.
 902
 903        Returns a context manager. In its block, assignments change only
 904        this object's copy of the parameters (as with
 905        `PulsePalDevice.auto_sync` off), and when the block ends,
 906        `PulsePalDevice.sync_to_device` sends all of them at once. If the
 907        block raises an error, nothing is sent, and the parameters go
 908        back to what they were.
 909
 910        ```python
 911        with P.batch():
 912            P.phase1_voltage = [5, 5, 2.5, 2.5]
 913            P.phase1_duration[3] = 0.002
 914        ```
 915
 916        In param sync mode, the device stores the parameters for the next
 917        rising edge on the param sync channel (see
 918        `PulsePalDevice.trigger_mode`).
 919        """
 920        return _common.batch(self)
 921
 922    def sync_to_device(self):
 923        """Program the device with this object's copy of all parameters.
 924
 925        Sends every output and trigger parameter to the device in a single
 926        command. Use it after assignments made with
 927        `PulsePalDevice.auto_sync` off; with it on, they have already
 928        reached the device.
 929
 930        ```python
 931        P.auto_sync = False
 932        P.phase1_voltage[1:5] = [5] * 4
 933        P.sync_to_device()
 934        ```
 935
 936        On Pulse Pal 3, if either trigger channel is in param sync mode
 937        (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores
 938        the parameters instead of programming them, and loads them on the
 939        next rising edge of that channel. The device still checks every
 940        value. This is the only method whose effect is deferred that way.
 941
 942        Raises:
 943            PulsePalError: If a value is out of range for its parameter
 944                (nothing is sent), or the device does not acknowledge the
 945                command.
 946        """
 947        self._check_sync()
 948        self._send_sync()
 949
 950    def sync_from_device(self):
 951        """Read all parameters from the device into this object's copy.
 952
 953        Overwrites the parameter lists with the program currently on the
 954        device, e.g. after it has been reprogrammed from its thumb
 955        joystick. `PulsePalDevice.continuous_loop` is left as it is: the
 956        device does not report it. Changes made with
 957        `PulsePalDevice.auto_sync` off and not yet sent are discarded, but
 958        for `PulsePalDevice.continuous_loop`.
 959
 960        Requires firmware v22 or newer.
 961
 962        Raises:
 963            PulsePalError: If the connected firmware is older than v22,
 964                or the device does not return the full parameter set.
 965        """
 966        self._require_firmware(22, "sync_from_device()")
 967        for name, values in self._read_device_params().items():
 968            getattr(self, name)._store(values)
 969        self._unsent = self._unsent & {"continuous_loop"}  # Not read: a change to it is still unsent
 970
 971    def set_default_params(self):
 972        """Program the device with the default parameters.
 973
 974        The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart
 975        (about 909 a second), for 1 second, resting at 0 V, on all four
 976        output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms
 977        after 0.1 ms at rest. No output channel is linked to a trigger
 978        channel, so a TTL starts nothing until a script links it, and both
 979        trigger channels are in normal mode. They are the device's own
 980        defaults, which it also loads at startup, after a comm failure
 981        and after formatting its microSD card. They are sent at once,
 982        also while `PulsePalDevice.auto_sync` is off. The constructor
 983        calls it.
 984        """
 985        for name, value in _DEFAULT_OUTPUT_VALUES.items():
 986            getattr(self, name)._store([value] * 4)
 987        self._trigger_mode._store(["Normal"] * 2)
 988        if self._has_param_sync():
 989            # A device left in param sync mode would store the sync below instead of running it,
 990            # leaving the device on its old program until a TTL arrived. A trigger mode sent on
 991            # its own is not deferred that way, so both trigger channels leave param sync mode
 992            # first. See PulsePalDevice.trigger_mode.
 993            self._send_trigger_modes(["Normal"] * 2, "set_default_params()")
 994        self._send_sync()
 995
 996    def export_params(self):
 997        """Return every parameter, as a dict of plain lists.
 998
 999        Keyed by parameter name (`DeviceInfo.output_parameter_names`, then
1000        `"trigger_mode"`), with one value per channel and no unused index
1001        0. It holds only numbers, booleans and names, so it can be saved
1002        with `json` and logged with your data, to record exactly what the
1003        device played. `PulsePalDevice.import_params` programs it again.
1004
1005        ```python
1006        import json
1007        with open("trial_params.json", "w") as f:
1008            json.dump(P.export_params(), f)
1009        ```
1010        """
1011        names = [*self.info.output_parameter_names, "trigger_mode"]
1012        return {name: list(getattr(self, name))[1:] for name in names}
1013
1014    def import_params(self, params):
1015        """Program the device with parameters exported by `PulsePalDevice.export_params`.
1016
1017        All of them are checked first, and then sent in one command, as
1018        at the end of a `PulsePalDevice.batch` block: in param sync mode,
1019        the device stores them for the next sync edge. Parameters missing
1020        from `params` keep their values. A name that is not a parameter
1021        is skipped with a warning, so that parameters exported by a newer
1022        version of this package, which may have more, can still be
1023        imported; if none is, an error is raised.
1024
1025        A whole program file, as the GUIs save it, is imported too: its
1026        parameters, not its custom trains (a warning says so, if it has
1027        any).
1028
1029        ```python
1030        with open("MyProgram.json") as f:
1031            P.import_params(json.load(f))
1032        ```
1033
1034        Args:
1035            params: A dict of parameter name to one value per channel (a
1036                list, tuple or NumPy array), as `export_params` returns,
1037                or a program file's contents. The MATLAB class's names
1038                (`phase1Voltage`) are accepted too, so that the parameters
1039                its `exportParams()` returns, saved with `jsonencode`, can
1040                be imported.
1041
1042        Raises:
1043            PulsePalError: If a value is invalid, or no name in `params` is
1044                a parameter. Nothing is sent, and the parameters keep the
1045                values they had.
1046        """
1047        params = _params_to_import(params)
1048        with self.batch():
1049            for name, values in params.items():
1050                setattr(self, name, values)
1051
1052    def set_fixed_voltage(self, channels, voltage):
1053        """Set output channels to a fixed voltage.
1054
1055        Each channel holds the voltage until it is set again or until a
1056        pulse train is triggered on it.
1057
1058        ```python
1059        P.set_fixed_voltage(4, 2.5)
1060        P.set_fixed_voltage([1, 3], -1)
1061        ```
1062
1063        Args:
1064            channels: Output channel number, 1-4, or several as a list,
1065                tuple or NumPy array.
1066            voltage: Voltage to set on each of them, in volts [-10, 10].
1067
1068        Raises:
1069            PulsePalError: If a channel number is not 1-4, the voltage is
1070                outside [-10, 10] (nothing is sent), or the device does not
1071                acknowledge the command.
1072        """
1073        channels = _common.channel_numbers(channels)
1074        voltage_bits = self._volts_to_bits(voltage, "voltage")
1075        for channel in channels:
1076            self._write_serial(
1077                (self._OP_MENU_BYTE, 79, channel),
1078                "uint8",
1079                voltage_bits,
1080                "uint16",
1081            )
1082            self._read_ack("set_fixed_voltage()")
1083
1084    def set_calibration(self, channel, voltage_offset):
1085        """Calibrate the zero code of an output channel.
1086
1087        The offset is added to every voltage the channel produces, to
1088        correct for DAC offset error. It is stored in the device's
1089        EEPROM and reloaded on boot, so it only needs to be set once.
1090
1091        Requires Pulse Pal hardware v3 or newer.
1092
1093        Args:
1094            channel: Output channel number, 1-4.
1095            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
1096
1097        Raises:
1098            PulsePalError: If the connected hardware is older than v3,
1099                `channel` is not 1-4, `voltage_offset` is outside
1100                [-0.1, 0.1], or the device does not acknowledge the
1101                command.
1102        """
1103        if self.info.hardware_version < 3:
1104            raise PulsePalError(
1105                "set_calibration() requires hardware v3 or newer."
1106            )
1107        if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)):
1108            raise PulsePalError("channel must be 1, 2, 3 or 4")
1109        if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1):  # Also refuses NaN
1110            raise PulsePalError(
1111                "voltage_offset for zero code calibration must be in range "
1112                "[-0.1, 0.1]"
1113            )
1114        # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it
1115        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
1116        self._write_serial(
1117            (self._OP_MENU_BYTE, 96, channel - 1),
1118            "uint8",
1119            voltage_bits,
1120            "int16",
1121        )
1122        self._read_ack("set_calibration()")
1123
1124    def set_screen_saver(self, enabled, timeout=1800):
1125        """Switch the device's screen saver on or off, and set its timeout.
1126
1127        With the screen saver on, the device dims its screen once it has
1128        been left alone for `timeout` seconds: no command from the
1129        computer, no rising edge on a trigger channel, and no joystick
1130        click or push. The next of these brings the screen back. Both
1131        settings are stored in the device's EEPROM and kept through power
1132        cycles; the screen saver can also be switched on and off from the
1133        device's joystick menu. A new device has it on, with 1800 s.
1134
1135        The device saves the settings once no channel is playing. Saving
1136        pauses its timer, usually for about 20 us but, once in about 2000
1137        changes, for tens of milliseconds, which would delay a trigger.
1138        Change the settings before an experiment rather than during one.
1139
1140        Requires firmware v22 or newer. Only Pulse Pal 3 has a screen
1141        saver: Pulse Pal 2 accepts `enabled=False` only.
1142
1143        ```python
1144        P.set_screen_saver(True, 300)  # dim after 5 minutes
1145        P.set_screen_saver(False)
1146        ```
1147
1148        Args:
1149            enabled: True (or 1) to switch the screen saver on, False (or
1150                0) to switch it off.
1151            timeout: Seconds without activity before the screen dims, a
1152                whole number from 1 to 65535. It is sent with every call,
1153                so leaving it out sets 1800 s.
1154
1155        Raises:
1156            PulsePalError: If the firmware is older than v22, the
1157                screen saver is switched on with hardware older than v3,
1158                a value is out of range, or the device does not
1159                acknowledge the command.
1160        """
1161        self._require_firmware(22, "set_screen_saver()")
1162        enabled = to_bool(enabled, "enabled")
1163        if not (
1164            isinstance(timeout, numbers.Real)
1165            and not isinstance(timeout, bool)
1166            and math.isfinite(timeout)
1167            and timeout == int(timeout)
1168            and 1 <= timeout <= 65535
1169        ):
1170            raise PulsePalError(
1171                "timeout must be a whole number of seconds from 1 to 65535"
1172            )
1173        if enabled and (self.info.hardware_version or 2) < 3:
1174            raise PulsePalError(
1175                "The screen saver requires hardware v3 or newer: Pulse Pal 2 "
1176                "accepts set_screen_saver(False) only."
1177            )
1178        self._write_serial(
1179            (self._OP_MENU_BYTE, 99, int(enabled)),
1180            "uint8",
1181            int(timeout),
1182            "uint16",
1183        )
1184        self._read_ack("set_screen_saver()")
1185
1186    # ------------------------------------------------------------------
1187    # Custom trains, playback and settings files
1188    # ------------------------------------------------------------------
1189
1190    def send_custom_pulse_train(
1191        self,
1192        custom_train_id,
1193        pulse_times,
1194        voltages,
1195    ):
1196        """Load a custom pulse train onto the device.
1197
1198        A custom pulse train is an arbitrary list of pulse onset times
1199        and voltages, replacing the parametric pulse voltage and onset
1200        timing. Set `PulsePalDevice.custom_train_id` on an output channel
1201        to play the train there. Each pulse takes the channel's own phase
1202        durations; a biphasic pulse's second phase is its voltage with the
1203        sign reversed. A biphasic pulse always plays whole, and a pulse
1204        whose time comes while it plays is dropped, so that every pulse
1205        played is on time.
1206
1207        ```python
1208        P.send_custom_pulse_train(
1209            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1210        )
1211        P.custom_train_id[1] = 2
1212        ```
1213
1214        Loading a train stops the output channels playing it, and their
1215        next trigger plays the new train. On Pulse Pal 3, while a trigger
1216        channel is in param sync mode (see `PulsePalDevice.trigger_mode`),
1217        the device stores the train instead, as it stores the parameters
1218        `PulsePalDevice.sync_to_device` sends: channels go on playing the
1219        old train, and the next rising edge on the param sync channel
1220        makes the new one current. A channel playing the old train at that
1221        edge finishes it, and plays the new one from its next trigger. So
1222        the next trial's custom trains can be loaded during the current
1223        trial. Leaving param sync mode makes a stored train current at
1224        once.
1225
1226        Args:
1227            custom_train_id: Custom train to load, from 1 to
1228                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1229                4 on Pulse Pal 3).
1230            pulse_times: Pulse onset times, in seconds, relative to the
1231                start of the train, increasing. Each is a multiple of
1232                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1233                100 us on Pulse Pal 2), from 0 to
1234                `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array.
1235            voltages: Voltage of each pulse, in volts [-10, 10]. Must be
1236                the same length as `pulse_times`.
1237
1238        Raises:
1239            PulsePalError: If `custom_train_id` is out of range,
1240                `pulse_times` or `voltages` is not one-dimensional (a 2-D
1241                array is refused, not flattened), they differ in length,
1242                there are no pulses, or more than
1243                `DeviceInfo.max_custom_pulses`, a pulse time is not a
1244                multiple of `DeviceInfo.min_pulse_width_us`, is out of
1245                range or is not later than the one before it, or the
1246                device does not acknowledge the command.
1247        """
1248        pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times")
1249        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1250        if len(pulse_times_cycles) != len(pulse_voltage_bits):
1251            raise PulsePalError(
1252                "pulse_times and voltages must be the same length."
1253            )
1254        self._send_custom_train(
1255            custom_train_id,
1256            pulse_times_cycles,
1257            pulse_voltage_bits,
1258            "send_custom_pulse_train()",
1259        )
1260
1261    def send_custom_waveform(
1262        self,
1263        custom_train_id,
1264        sampling_period,
1265        voltages,
1266    ):
1267        """Load an arbitrary waveform onto the device.
1268
1269        A convenience shorthand for
1270        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1271        adjoining pulses, so that `voltages` is played as a waveform
1272        sampled every `sampling_period` seconds. It is loaded as a custom
1273        train is: see `PulsePalDevice.send_custom_pulse_train` for what
1274        happens to channels playing it, and in param sync mode.
1275
1276        Set the channel's `PulsePalDevice.phase1_duration` to
1277        `sampling_period` as well, so that each sample is held for the
1278        sampling period.
1279
1280        ```python
1281        import math
1282
1283        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1284        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1285        P.custom_train_id[2] = 1
1286        P.phase1_duration[2] = 0.001
1287        ```
1288
1289        Args:
1290            custom_train_id: Custom train to load, from 1 to
1291                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1292                4 on Pulse Pal 3).
1293            sampling_period: In seconds: a multiple of
1294                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1295                100 us on Pulse Pal 2). Each voltage is
1296                held for this long.
1297            voltages: Waveform samples, in volts [-10, 10].
1298                Accepts a list, tuple or 1-D NumPy array.
1299
1300        Raises:
1301            PulsePalError: If `custom_train_id` is out of range, there
1302                are no samples, or more than
1303                `DeviceInfo.max_custom_pulses`, `sampling_period` is not a
1304                positive multiple of `DeviceInfo.min_pulse_width_us`, the
1305                last sample would start after `MAX_TIME`, or the device
1306                does not acknowledge the command.
1307        """
1308        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1309        period_cycles = self._custom_time_cycles(sampling_period, "sampling_period")
1310        if period_cycles == 0:
1311            raise PulsePalError(
1312                f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. "
1313                f"Received {sampling_period!r}."
1314            )
1315        pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64)
1316        self._send_custom_train(
1317            custom_train_id,
1318            pulse_times,
1319            pulse_voltage_bits,
1320            "send_custom_waveform()",
1321        )
1322
1323    def trigger(self, channels):
1324        """Trigger output channels in software.
1325
1326        The channels start their pulse trains together, in the same timer
1327        cycle. A channel that is already playing a pulse train ignores
1328        the trigger, and `PulsePalDevice.stop` cancels a trigger that has
1329        not started its channel yet.
1330
1331        ```python
1332        P.trigger(3)          # one channel
1333        P.trigger([1, 4])     # several: a list, tuple or NumPy array
1334        ```
1335
1336        Args:
1337            channels: Output channel number, 1-4, or several as a list,
1338                tuple or NumPy array.
1339
1340        Raises:
1341            PulsePalError: If a channel number is not 1-4, or no channel
1342                is given. Nothing is triggered.
1343        """
1344        self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8")
1345
1346    def stop(self, channels=None):
1347        """Stop pulse trains currently playing on the device.
1348
1349        The stopped channels return to their
1350        `PulsePalDevice.resting_voltage`. A soft trigger that has not
1351        started its channel yet is cancelled too.
1352
1353        ```python
1354        P.stop()           # all output channels
1355        P.stop([1, 3, 4])  # channels 1, 3 and 4
1356        ```
1357
1358        Args:
1359            channels: Output channel number, 1-4, or several as a list,
1360                tuple or NumPy array. `None`, the default, stops all
1361                channels. Stopping some channels requires firmware v22
1362                or newer.
1363
1364        Raises:
1365            PulsePalError: If a channel number is not 1-4, or channels are
1366                given and the firmware is older than v22.
1367        """
1368        if self.info.firmware_version < 22:
1369            if channels is not None:
1370                raise PulsePalError("stop() cannot address individual channels before firmware v22")
1371            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")  # Stops all channels
1372            return
1373        bits = 0x0F if channels is None else _common.channel_bits(channels)
1374        self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8")
1375
1376    def status(self):
1377        """Read which channels are playing, and the counters since the last call.
1378
1379        The counters (see `DeviceStatus`) count from the previous call, or
1380        from the device's startup, and each call clears them. Calling it
1381        before and after a trial checks that the TTLs the trial sent
1382        arrived, and started the trains they should:
1383
1384        ```python
1385        P.status()                     # Clears the counters
1386        run_trial()                    # Your code: one TTL on trigger channel 1, linked to channels 1 and 2
1387        s = P.status()
1388        if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]:
1389            print("The trial's trains did not all start")
1390        ```
1391
1392        Requires firmware v22 or newer.
1393
1394        Returns:
1395            A `DeviceStatus`.
1396
1397        Raises:
1398            PulsePalError: If the firmware is older than v22, or the device
1399                does not reply.
1400        """
1401        self._require_firmware(22, "status()")
1402        self._write_serial((self._OP_MENU_BYTE, 101), "uint8")
1403        values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES))
1404        return DeviceStatus(
1405            playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))],
1406            trigger_edges=[None, *values[1:3]],
1407            channel_starts=[None, *values[3:7]],
1408            comm_failures=values[7],
1409        )
1410
1411    def save_program_to_microsd(self, file_name):
1412        """Save the parameters to the device's microSD card, as a program.
1413
1414        The program can be loaded later with
1415        `PulsePalDevice.load_program_from_microsd` or with the joystick
1416        menu's Load Program item. A file of the same name is replaced. To
1417        keep the parameters on this computer instead, see
1418        `PulsePalDevice.export_params`.
1419
1420        ```python
1421        P.save_program_to_microsd("MyProtocol.pps")
1422        ```
1423
1424        Args:
1425            file_name: At most 15 ASCII characters, ending in `.pps`.
1426
1427        Raises:
1428            PulsePalError: If the file name is invalid, or the device could
1429                not write the file to its microSD card. Pulse Pal 2 also
1430                refuses while a channel is playing: its card shares a bus with
1431                the outputs, so stop playback first.
1432        """
1433        self._settings_file_op(file_name, 1, "save_program_to_microsd()")
1434
1435    def load_program_from_microsd(self, file_name):
1436        """Load a program from the device's microSD card.
1437
1438        The device plays the program, and the parameters are read back
1439        into this object (see `PulsePalDevice.sync_from_device`). The
1440        program's trigger modes take effect too, and it is loaded at once,
1441        also in param sync mode.
1442
1443        Args:
1444            file_name: The program's file name, ending in `.pps`, as saved
1445                by `PulsePalDevice.save_program_to_microsd` or the joystick
1446                menu.
1447
1448        Raises:
1449            PulsePalError: If the file name is invalid, or the device could
1450                not load the program: there is no such file on its microSD
1451                card, or it is not a valid Pulse Pal program; or, on Pulse
1452                Pal 2, a channel is playing (see `save_program_to_microsd`).
1453                The device then keeps its parameters, and so does this object.
1454        """
1455        self._settings_file_op(file_name, 2, "load_program_from_microsd()")
1456
1457    def delete_program_from_microsd(self, file_name):
1458        """Delete a program from the device's microSD card.
1459
1460        Args:
1461            file_name: The program's file name, ending in `.pps`.
1462
1463        Raises:
1464            PulsePalError: If the file name is invalid, or the device could
1465                not delete the file, e.g. because there is no such file, or,
1466                on Pulse Pal 2, because a channel is playing.
1467        """
1468        self._settings_file_op(file_name, 3, "delete_program_from_microsd()")
1469
1470    def _settings_file_op(self, file_name, op_byte, context):
1471        """Save (op_byte 1), load (2) or delete (3) a settings file, with op 90."""
1472        valid = (
1473            isinstance(file_name, str)
1474            and all(32 <= ord(character) <= 126 for character in file_name)  # Printable ASCII
1475            and file_name.lower().endswith(".pps")
1476            and 4 < len(file_name) <= 15
1477        )
1478        if not valid:
1479            raise PulsePalError(
1480                f"{context}: the file name must be 1 to 11 ASCII characters followed by .pps, "
1481                f"e.g. 'Protocol1.pps'. Received {file_name!r}."
1482            )
1483        filename_bytes = file_name.encode("ascii")
1484        self._write_serial(
1485            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1486            "uint8",
1487            list(filename_bytes),
1488            "uint8",
1489        )
1490        if self.info.firmware_version > 21:
1491            # Sent after the file operation has finished. A refused load changes nothing on the device.
1492            refusal = {
1493                1: f"Pulse Pal could not save {file_name!r} to its microSD card. Is the card in place?",
1494                2: f"Pulse Pal could not load {file_name!r}: there is no such file on its microSD card, or it is not "
1495                   "a valid Pulse Pal program. The device keeps its parameters.",
1496                3: f"Pulse Pal could not delete {file_name!r}: there is no such file on its microSD card.",
1497            }[op_byte]
1498            # Pulse Pal 2 replies 2 while a channel plays: its microSD card shares the SPI bus with the DAC
1499            busy = (f"Pulse Pal 2 does not use its microSD card while a channel is playing, because the card shares a "
1500                    f"bus with the outputs, so {file_name!r} was not {('saved', 'loaded', 'deleted')[op_byte - 1]}. "
1501                    "Stop playback first, with stop().")
1502            if op_byte == 2:
1503                busy += " The device keeps its parameters."
1504            self._read_ack(context, refusal_message=refusal, refusal_messages={2: busy})
1505        else:
1506            self._reply_pending = None  # Firmware v21 does not acknowledge op 90 (see _write_raw())
1507            if op_byte == 2:
1508                time.sleep(0.1)  # So allow time for the load
1509        if op_byte == 2:
1510            self.sync_from_device()
1511
1512    def format_microsd(self, *, confirm=True, timeout=30):
1513        """Format the device's microSD card.
1514
1515        Erases every program saved on the device and resets its
1516        parameters to the defaults. By default, asks at the console for
1517        confirmation first; a script that runs on its own, or an AI agent,
1518        passes `confirm=False`.
1519
1520        Requires Pulse Pal hardware v3 or newer.
1521
1522        Args:
1523            confirm: If `True`, ask at the console before anything is
1524                erased. If `False`, format at once.
1525            timeout: Seconds to wait for the device to report that
1526                formatting has finished.
1527
1528        Returns:
1529            `True` once the card has been formatted, or `False` if the
1530            user declines the confirmation prompt.
1531
1532        Raises:
1533            PulsePalError: If the connected hardware is older than v3, if
1534                the device reports that formatting failed, or if it does
1535                not report a result within `timeout` seconds.
1536        """
1537        if self.info.hardware_version < 3:
1538            raise PulsePalError(
1539                "format_microsd() requires hardware v3 or newer."
1540            )
1541
1542        if to_bool(confirm, "confirm"):
1543            print("*** Pulse Pal microSD Formatter ***")
1544            print("This will format Pulse Pal's microSD card,")
1545            print("erase all programs saved on the device")
1546            print("and reset all parameters to defaults.")
1547            reply = input("Do you want to continue (y/n)")
1548            if reply.strip().lower() != "y":
1549                print("Choice confirmed - microSD Card NOT formatted.")
1550                return False
1551
1552        self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8")
1553
1554        # The device replies with lines of status text, the last of which
1555        # contains "!", and then a confirm byte (1 if the card was formatted,
1556        # 0 if not). The confirm byte is sent after the device has reloaded
1557        # its default parameters, so it can arrive well after the text. It
1558        # must be read here: left in the buffer, it would be taken as the
1559        # reply to the next command, and every reply after that would be
1560        # read one byte late.
1561        start = time.time()
1562        message = bytearray()
1563        flag_index = -1
1564        line_end = -1
1565
1566        while time.time() - start < timeout:
1567            n_waiting = self.port.in_waiting
1568            if n_waiting:
1569                message.extend(self.port.read(n_waiting))
1570                flag_index = message.find(b"!")
1571                if flag_index >= 0:
1572                    line_end = message.find(b"\n", flag_index)
1573                if line_end >= 0 and len(message) > line_end + 1:
1574                    break
1575            time.sleep(0.01)
1576        if line_end < 0 or len(message) <= line_end + 1:
1577            raise PulsePalError(
1578                "Pulse Pal did not report the result of formatting "
1579                f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}"
1580            )
1581        self._reply_pending = None  # The whole reply is read (see _write_raw())
1582        confirm_byte = message[line_end + 1]
1583
1584        text = bytes(message[:flag_index]).decode(
1585            "ascii", errors="replace"
1586        ).rstrip()
1587        if text:
1588            print(text)
1589
1590        # The device has loaded its own defaults. They are this class's, but the device keeps no record of
1591        # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the
1592        # MATLAB class does, and this object describes the device again.
1593        self.set_default_params()
1594        if confirm_byte != 1:
1595            raise PulsePalError(
1596                "Pulse Pal could not format its microSD card."
1597            )
1598        return True
1599
1600    def gui(self, block=None, theme=None):
1601        """Open the Pulse Pal parameter GUI, or focus an open one.
1602
1603        The GUI edits its own copy of the parameters, and loads them to
1604        the device when its 'Load to Device' button is clicked. The
1605        window closes automatically when the device is closed or
1606        deleted.
1607
1608        Calling this while the GUI is already open focuses the existing
1609        window rather than opening a second one.
1610
1611        Args:
1612            block: If `True`, the call returns when the GUI is closed. If
1613                `False`, the call returns immediately, and the host
1614                application must run the Tk event loop. If `None`, the
1615                GUI blocks only when the host does not already provide a
1616                Tk event loop, e.g. when launched from a script.
1617            theme: `"light"` or `"dark"` to select the color theme, or
1618                `None` to match the desktop theme. Passing a theme to an
1619                already-open GUI recolors it in place.
1620
1621        Returns:
1622            The `pulsepal.gui.PulsePalGUI` instance driving the window.
1623
1624        Raises:
1625            ValueError: If the theme name is not recognized.
1626        """
1627        gui = getattr(self, "_gui", None)
1628        if gui is not None and not gui.is_closed:
1629            if theme is not None:
1630                gui.set_theme(theme)
1631            gui.focus()
1632            return gui
1633
1634        from .gui import PulsePalGUI
1635
1636        gui = PulsePalGUI(self, theme=theme)
1637        self._gui = gui
1638        gui.start(block=block)
1639        return gui
1640
1641    # ------------------------------------------------------------------
1642    # Connection
1643    # ------------------------------------------------------------------
1644
1645    def close(self):
1646        """Close the connection to the device, and the GUI if open.
1647
1648        The device stops all output channels when the client disconnects,
1649        and shows its own name on its screen again. Safe to call more
1650        than once; later calls do nothing. Called automatically when
1651        leaving a `with` block and when the object is garbage collected,
1652        so pulse trains also stop when the last reference to the object
1653        goes, e.g. when a function that created it returns.
1654        """
1655        self._close(send_disconnect=True)
1656
1657    def _close(self, send_disconnect):
1658        """Close the connection. send_disconnect: tell the device first (op 81). False when the device is in
1659        an unknown state, such as after a failed handshake."""
1660        gui = getattr(self, "_gui", None)
1661        self._gui = None
1662        if gui is not None:
1663            try:
1664                gui.close()
1665            except Exception:
1666                # Cleanup must not raise; Tk may already be torn down
1667                pass
1668
1669        if getattr(self, "_closed", True):
1670            return
1671        self._closed = True
1672        if send_disconnect and self._unsent:
1673            _common.warn(f"PulsePalDevice closed with changes made with auto_sync off that were never sent to the "
1674                         f"device: {self._unsent_names()}.")
1675        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1676        try:
1677            if send_disconnect and self.port and self.port.is_open:
1678                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1679        except Exception:
1680            pass  # The port may already be gone, e.g. the cable was unplugged
1681        finally:
1682            if self.port and self.port.is_open:
1683                self.port.close()
1684
1685    def __enter__(self):
1686        """Enter a `with` block, returning the connected device."""
1687        return self
1688
1689    def __exit__(self, exc_type, exc_value, traceback):
1690        """Disconnect and close the port when leaving a `with` block.
1691
1692        Returns:
1693            `False`, so any exception raised in the block propagates.
1694        """
1695        self.close()
1696        return False
1697
1698    def __del__(self):
1699        """Disconnect and close the port when the object is collected."""
1700        try:
1701            self.close()
1702        except Exception:
1703            # Destructors should not raise; the serial object may already be
1704            # gone during interpreter shutdown.
1705            pass
1706
1707    def __setattr__(self, name, value):
1708        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1709        _common.refuse_unknown_attribute())."""
1710        if name[0] != "_":
1711            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1712        super().__setattr__(name, value)
1713
1714    def __repr__(self):
1715        """Describe the device and its parameters, e.g. with `print(P)`."""
1716        port = getattr(getattr(self, "port", None), "port", None)
1717        lines = [
1718            f"PulsePalDevice on {port} (Pulse Pal {self.info.hardware_version}, "
1719            f"firmware v{self.info.firmware_version})",
1720            f"auto_sync: {self._auto_sync}",
1721        ]
1722        for name in self.info.output_parameter_names:
1723            lines.append(f"{name}: {list(getattr(self, name))}")
1724        lines.append(f"trigger_mode: {list(self._trigger_mode)}")
1725        return "\n".join(lines)
1726
1727    # ------------------------------------------------------------------
1728    # Internals: parameters
1729    # ------------------------------------------------------------------
1730
1731    def _has_param_sync(self):
1732        """Param sync mode is on Pulse Pal 3, with firmware v22 or newer."""
1733        return (self.info.hardware_version or 2) > 2 and (self.info.firmware_version or 0) > 21
1734
1735    def _normalize_output_value(self, code, value, channel):
1736        """Check a value for an output parameter, and return it as the parameter's list holds it:
1737        a time as the device plays it, rounded to its timer cycle.
1738
1739        Raises PulsePalError, before anything is sent, for a value the device cannot play. The
1740        device refuses most of them too, but it also resets them (see validateOutputParams() in
1741        /Firmware/PulsePal/USBOps.ino), so the local copy would describe a different program.
1742        """
1743        name, kind = _OUTPUT_PARAMETERS[code]
1744        label = f"{name} on channel {channel}"
1745        if kind == "bool":
1746            value = to_bool(value, label)
1747            if code == _CONTINUOUS_LOOP_CODE and value and self.info.firmware_version < 22:
1748                raise PulsePalError("continuous_loop requires firmware v22 or newer.")
1749            return value
1750        if kind == "volts":
1751            self._volts_to_bits(value, label)
1752            return float(value)
1753        if kind in ("time", "pulse_time"):
1754            cycles = self._seconds_to_cycles(value, label)
1755            # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001,
1756            # but is exactly 10 cycles of 10 us
1757            if kind == "pulse_time" and cycles < self._min_pulse_cycles():
1758                raise PulsePalError(
1759                    f"{label} must be at least {self._min_pulse_seconds()} s "
1760                    f"({self._min_pulse_cycles_text()} of the device's {self.info.cycle_period_us} us "
1761                    f"timer), so that trigger channels can detect the pulses. Received {value!r}."
1762                )
1763            return self._cycles_to_seconds(cycles)  # The time the device plays
1764        if kind == "train_id":
1765            self._check_whole_number(value, label, 0, self.info.n_custom_pulse_trains)
1766            return int(value)
1767        # "target"
1768        return to_name(value, CUSTOM_TRAIN_TARGETS, label, codes={"Pulses": 0, "Bursts": 1})
1769
1770    def _normalize_trigger_mode(self, value):
1771        modes = self.info.trigger_modes
1772        return to_name(value, modes, "trigger_mode", codes={mode: TRIGGER_MODES.index(mode) for mode in modes})
1773
1774    def _encode_output_param(self, code, values):
1775        """Convert output parameter values to device units.
1776
1777        Returns the converted values and the datatype the device reads for this
1778        parameter code: DAC codes for voltages, hardware timer cycles for times,
1779        and bytes for everything else.
1780        """
1781        name, kind = _OUTPUT_PARAMETERS[code]
1782        if kind == "volts":
1783            return [self._volts_to_bits(v, name) for v in values], "uint16"
1784        if kind in ("time", "pulse_time"):
1785            return [self._seconds_to_cycles(v, name) for v in values], "uint32"
1786        if kind == "target":
1787            return [CUSTOM_TRAIN_TARGETS.index(v) for v in values], "uint8"
1788        return [int(v) for v in values], "uint8"
1789
1790    def _apply_output_param(self, code, values):
1791        """Check new values for all four channels of an output parameter, and with auto_sync on,
1792        program the channels whose value changes. Returns them as the parameter's list holds them."""
1793        values = [self._normalize_output_value(code, v, ch) for ch, v in enumerate(values, start=1)]
1794        name = _OUTPUT_PARAMETERS[code][0]
1795        if not self._auto_sync:
1796            self._unsent = self._unsent | {name}  # Until sync_to_device() sends it (see auto_sync)
1797        else:
1798            if code in (_BURST_DURATION_CODE, _CUSTOM_TRAIN_TARGET_CODE):
1799                # Sent alone, so it must suit the other parameter as the device holds it. In a batch() block, or
1800                # with auto_sync off, both are checked together by sync_to_device().
1801                new = {name: values}
1802                self._check_custom_bursts(new.get("custom_train_target", list(self._custom_train_target)[1:]),
1803                                          new.get("burst_duration", list(self._burst_duration)[1:]))
1804            # Compared as the device receives them: 5 and 5.00001 V are the same DAC code
1805            old_data, _ = self._encode_output_param(code, list(getattr(self, name))[1:])
1806            new_data, _ = self._encode_output_param(code, values)
1807            channels = [ch for ch in range(1, 5) if old_data[ch - 1] != new_data[ch - 1]]
1808            self._send_output_param(code, values, channels, f"setting {name}")
1809        return values
1810
1811    def _send_output_param(self, code, values, channels, context):
1812        """Program one output parameter on some output channels: op 91 if all four change, op 74 for each otherwise.
1813
1814        The other channels are left as the device holds them. Sending all four (op 91) for a change to one put this
1815        object's copy of the others back on the device, undoing what the joystick menu, or a program loaded with it,
1816        had set there.
1817        """
1818        if self.info.firmware_version > 21 and len(channels) == 4:
1819            data, datatype = self._encode_output_param(code, values)
1820            self._write_serial((self._OP_MENU_BYTE, 91, code), "uint8", data, datatype)
1821            self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, channels))
1822            return
1823        for channel in channels:
1824            # A refusal reads back every channel this assignment changes, so that the copy describes the device for
1825            # those already sent too
1826            self._send_output_param_to_channel(code, channel, values[channel - 1], context, refresh_channels=channels)
1827
1828    def _send_output_param_to_channel(self, code, channel, value, context, refresh_channels=None):
1829        """Program one output parameter on one channel (op 74)."""
1830        if code == _CONTINUOUS_LOOP_CODE and self.info.firmware_version < 22:
1831            return  # Firmware v21 has no parameter 18, and _normalize_output_value() lets only False through
1832        data, datatype = self._encode_output_param(code, [value])
1833        self._write_serial((self._OP_MENU_BYTE, 74, code, channel), "uint8", data, datatype)
1834        self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, refresh_channels or (channel,)))
1835
1836    def _apply_trigger_mode(self, values):
1837        """Check new trigger modes, and with auto_sync on, program the trigger channels whose mode changes."""
1838        modes = [self._normalize_trigger_mode(v) for v in values]
1839        if not self._auto_sync:
1840            self._unsent = self._unsent | {"trigger_mode"}
1841        else:
1842            channels = [ch for ch in (1, 2) if modes[ch - 1] != self._trigger_mode[ch]]
1843            if len(channels) == 2:
1844                self._send_trigger_modes(modes, "setting trigger_mode")
1845            elif channels:  # One: op 74, which leaves the other trigger channel as the device holds it
1846                channel = channels[0]
1847                self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel,
1848                                    TRIGGER_MODES.index(modes[channel - 1])), "uint8")
1849                self._read_ack("setting trigger_mode", on_refusal=self._refresh_trigger_mode)
1850        return modes
1851
1852    def _send_trigger_modes(self, modes, context):
1853        """Program both trigger channels' modes: op 91, or op 74 per channel on firmware v21."""
1854        codes = [TRIGGER_MODES.index(mode) for mode in modes]
1855        if self.info.firmware_version > 21:
1856            self._write_serial((self._OP_MENU_BYTE, 91, _TRIGGER_MODE_CODE, *codes), "uint8")
1857            self._read_ack(context, on_refusal=self._refresh_trigger_mode)
1858        else:
1859            for channel, mode_code in enumerate(codes, start=1):
1860                self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, mode_code), "uint8")
1861                self._read_ack(context)
1862
1863    def _snapshot(self):
1864        """All parameters, and which are unsent, for batch() to restore if its block fails."""
1865        names = [*self.info.output_parameter_names, "trigger_mode"]
1866        return {name: list(getattr(self, name))[1:] for name in names}, self._unsent
1867
1868    def _restore(self, snapshot):
1869        params, self._unsent = snapshot
1870        for name, values in params.items():
1871            getattr(self, name)._store(values)
1872
1873    def _unsent_names(self):
1874        """The settings assigned with auto_sync off and not yet sent, for messages, in parameter code order."""
1875        return ", ".join(name for name in _SETTING_NAMES if name in self._unsent)
1876
1877    def _check_sync(self):
1878        """Check the whole local copy before sync_to_device() sends it."""
1879        for code, (name, _) in _OUTPUT_PARAMETERS.items():
1880            values = getattr(self, name)
1881            for ch in range(1, 5):
1882                self._normalize_output_value(code, values[ch], ch)
1883        for ch in (1, 2):
1884            self._normalize_trigger_mode(self._trigger_mode[ch])
1885        self._check_custom_bursts(list(self._custom_train_target)[1:], list(self._burst_duration)[1:])
1886
1887    @staticmethod
1888    def _check_custom_bursts(targets, burst_durations):
1889        """Refuse a custom train targeting bursts on a channel with no burst duration.
1890
1891        Its times are burst onsets, and a burst of no duration plays one timer cycle of a pulse at each: a
1892        program that cannot be what was meant. (The device once played one pulse and then left the channel
1893        "playing", ignoring triggers until it was stopped.)
1894        """
1895        for channel, (target, burst) in enumerate(zip(targets, burst_durations), start=1):
1896            if target == "Bursts" and burst == 0:
1897                raise PulsePalError(
1898                    f"Output channel {channel}: custom_train_target 'Bursts' makes the custom train's times burst "
1899                    "onsets, so burst_duration must be above 0. Set burst_duration first, or set both in a batch() "
1900                    "block."
1901                )
1902
1903    def _send_sync(self):
1904        """Send every parameter in one command: op 92 on firmware v22 or newer, op 73 on v21."""
1905        self._unsent = frozenset()  # All are sent below
1906        if self.info.firmware_version > 21:
1907            self._sync_all_params()
1908        else:
1909            self._sync_all_params_legacy()
1910        self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)
1911
1912    def _device_bytes(self, name):
1913        """The byte values of a byte parameter for channels 1-4, as the device reads them."""
1914        values = list(getattr(self, name))[1:]
1915        if name == "custom_train_target":
1916            return [CUSTOM_TRAIN_TARGETS.index(v) for v in values]
1917        return [int(v) for v in values]
1918
1919    def _sync_all_params(self):
1920        """Send all parameters using the packed sync op (firmware v22+).
1921
1922        Values are grouped by width so that the whole program travels as
1923        one uint32 block, one uint16 block and one uint8 block. See op 92
1924        in /Firmware/PROTOCOL.md.
1925        """
1926        time_values = []
1927        for name in _TIME_PARAMETERS:
1928            time_values.extend(self._seconds_to_cycles(v) for v in list(getattr(self, name))[1:])
1929        voltage_values = []
1930        for name in _VOLTAGE_PARAMETERS:
1931            voltage_values.extend(self._volts_to_bits(v) for v in list(getattr(self, name))[1:])
1932        single_byte_values = []
1933        for name in ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop",
1934                     "continuous_loop", "link_trigger_channel1", "link_trigger_channel2"):
1935            single_byte_values.extend(self._device_bytes(name))
1936        single_byte_values.extend(TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:])
1937
1938        self._write_serial(
1939            (self._OP_MENU_BYTE, 92),
1940            "uint8",
1941            time_values,
1942            "uint32",
1943            voltage_values,
1944            "uint16",
1945            single_byte_values,
1946            "uint8",
1947        )
1948
1949    def _sync_all_params_legacy(self):
1950        """Send all parameters using the legacy sync op (firmware v21).
1951
1952        Equivalent to `_sync_all_params`, but lays the program out
1953        channel by channel as the older firmware expects. Continuous
1954        loop mode is not part of it.
1955        """
1956        program_values_32 = []
1957        program_values_16 = []
1958        program_values_8 = []
1959        for ch in range(1, 5):
1960            program_values_32.extend(self._seconds_to_cycles(getattr(self, name)[ch]) for name in _TIME_PARAMETERS)
1961        for ch in range(1, 5):
1962            program_values_16.extend(self._volts_to_bits(getattr(self, name)[ch]) for name in _VOLTAGE_PARAMETERS)
1963        byte_values = {name: self._device_bytes(name) for name in
1964                       ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop")}
1965        for ch in range(4):
1966            program_values_8.extend(byte_values[name][ch] for name in byte_values)
1967        links = self._device_bytes("link_trigger_channel1") + self._device_bytes("link_trigger_channel2")
1968        modes = [TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]]
1969
1970        self._write_serial(
1971            (self._OP_MENU_BYTE, 73),
1972            "uint8",
1973            program_values_32,
1974            "uint32",
1975            program_values_16,
1976            "uint16",
1977            program_values_8 + links + modes,
1978            "uint8",
1979        )
1980
1981    def _read_device_params(self):
1982        """Read the device's parameters (op 93).
1983
1984        Returns a dict of parameter values for channels 1-4 (trigger
1985        channels 1-2 for trigger_mode), as the parameter lists hold them.
1986        Continuous loop mode is not part of op 93, so `continuous_loop` is
1987        not included.
1988        """
1989        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
1990        # The device sends the whole parameter set as one message, so read it in one go and
1991        # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md.
1992        message = self._read_raw(self._PARAM_MESSAGE_BYTES)
1993        values = struct.unpack(f"{self._ENDIANNESS}32I12H26B", message)
1994        params = {}
1995        for index, name in enumerate(_TIME_PARAMETERS):
1996            params[name] = [self._cycles_to_seconds(x) for x in values[index * 4:index * 4 + 4]]
1997        for index, name in enumerate(_VOLTAGE_PARAMETERS):
1998            params[name] = [self._bits_to_volts(x) for x in values[32 + index * 4:32 + index * 4 + 4]]
1999        for index, name in enumerate(("is_biphasic", "custom_train_id", "custom_train_target",
2000                                      "custom_train_loop", "link_trigger_channel1", "link_trigger_channel2")):
2001            codes = values[44 + index * 4:44 + index * 4 + 4]
2002            if name == "custom_train_id":
2003                params[name] = list(codes)
2004            elif name == "custom_train_target":
2005                params[name] = [CUSTOM_TRAIN_TARGETS[min(code, 1)] for code in codes]
2006            else:
2007                params[name] = [bool(code) for code in codes]
2008        params["trigger_mode"] = [TRIGGER_MODES[min(code, 3)] for code in values[68:70]]
2009        return params
2010
2011    def _refresh_output_param(self, code, channels):
2012        """Read an output parameter back from the device into the local copy, for these channels.
2013
2014        Called after the device refused a value for them, which it resets. The parameter's other
2015        channels, and other parameters, keep their local values, which may hold edits not yet synced.
2016        """
2017        name = _OUTPUT_PARAMETERS[code][0]
2018        if self.info.firmware_version < 22 or code == _CONTINUOUS_LOOP_CODE:
2019            return  # No op 93 to read it with, or not in op 93
2020        device_values = self._read_device_params()[name]
2021        settings = getattr(self, name)
2022        local_values = list(settings)[1:]
2023        for ch in channels:
2024            local_values[ch - 1] = device_values[ch - 1]
2025        settings._store(local_values)
2026
2027    def _refresh_trigger_mode(self):
2028        """Read the trigger modes back from the device, after the device refused a new one."""
2029        if self.info.firmware_version > 21:
2030            self._trigger_mode._store(self._read_device_params()["trigger_mode"])
2031
2032    def _refresh_after_refused_sync(self):
2033        """Read the parameters back after the device refused a sync_to_device() set.
2034
2035        The device programs the set with the refused values reset, so the local copy is read back.
2036        In param sync mode the device stores the set instead, and op 93 would return the parameters
2037        playing now, so the local copy is left as it is.
2038        """
2039        if self.info.firmware_version > 21 and "Param Sync" not in list(self._trigger_mode)[1:]:
2040            self.sync_from_device()
2041
2042    def _check_whole_number(self, value, name, low, high):
2043        """Raise PulsePalError unless value is a whole number from low to high."""
2044        valid = (
2045            isinstance(value, numbers.Real)
2046            and not isinstance(value, (bool, np.bool_))
2047            and math.isfinite(value)
2048            and value == int(value)
2049            and low <= value <= high
2050        )
2051        if not valid:
2052            raise PulsePalError(
2053                f"{name} must be a whole number from {low} to {high}. Received {value!r}."
2054            )
2055
2056    def _min_pulse_cycles(self):
2057        """The shortest pulse phase, interval or train, in timer cycles: DeviceInfo.min_pulse_width_us. See
2058        _MIN_PULSE_CYCLES."""
2059        return int(round(self.info.min_pulse_width_us / self.info.cycle_period_us))
2060
2061    def _min_pulse_cycles_text(self):
2062        """_min_pulse_cycles() for messages: "1 cycle", "2 cycles"."""
2063        cycles = self._min_pulse_cycles()
2064        return f"{cycles} cycle" if cycles == 1 else f"{cycles} cycles"
2065
2066    def _min_pulse_seconds(self):
2067        """The shortest pulse phase, interval or train, in seconds: DeviceInfo.min_pulse_width_us."""
2068        return self._cycles_to_seconds(self._min_pulse_cycles())
2069
2070    def _require_firmware(self, minimum_version, context):
2071        """Raise unless the device firmware is new enough for an op."""
2072        if (
2073            self.info.firmware_version is None
2074            or self.info.firmware_version < minimum_version
2075        ):
2076            raise PulsePalError(
2077                f"{context} requires firmware v{minimum_version} or newer. "
2078                f"Detected firmware is v{self.info.firmware_version}."
2079            )
2080
2081    # ------------------------------------------------------------------
2082    # Internals: custom trains
2083    # ------------------------------------------------------------------
2084
2085    def _send_custom_train(self, custom_train_id, pulse_times_cycles,
2086                           pulse_voltage_bits, context):
2087        """Send a custom pulse train, already converted to device units (NumPy integer arrays).
2088
2089        Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76
2090        (trains 1 and 2 only) on firmware v21.
2091        """
2092        n_trains = self.info.n_custom_pulse_trains
2093        valid_id = (
2094            isinstance(custom_train_id, numbers.Integral)
2095            and not isinstance(custom_train_id, (bool, np.bool_))
2096            and 1 <= custom_train_id <= n_trains
2097        )
2098        if not valid_id:
2099            raise PulsePalError(
2100                f"{context}: custom_train_id must be an integer from 1 to "
2101                f"{n_trains}. Received {custom_train_id!r}."
2102            )
2103        train_id = int(custom_train_id)
2104        n_pulses = len(pulse_times_cycles)
2105        if n_pulses == 0:
2106            # The device refuses an empty train too: it would hold no pulse times to play
2107            raise PulsePalError(
2108                f"{context}: a custom train needs at least one pulse. To play nothing on a channel, set its "
2109                "custom_train_id to a train that holds what it should play, or do not trigger it."
2110            )
2111        if n_pulses > self.info.max_custom_pulses:
2112            raise PulsePalError(
2113                f"{context}: {n_pulses} pulses were given. Pulse Pal can "
2114                f"store up to {self.info.max_custom_pulses} pulses per "
2115                "custom train."
2116            )
2117        # Times are multiples of _min_pulse_cycles() (see _custom_times_to_cycles()), so increasing times are far
2118        # enough apart for a trigger channel to detect each pulse.
2119        too_close = np.flatnonzero(np.diff(pulse_times_cycles) < self._min_pulse_cycles())
2120        if too_close.size:
2121            i = int(too_close[0]) + 1
2122            raise PulsePalError(
2123                f"{context}: pulse times must increase, by at least "
2124                f"{self._min_pulse_seconds()} s ({self._min_pulse_cycles_text()} "
2125                f"of the device's {self.info.cycle_period_us} us "
2126                f"timer). Pulse {i + 1} is at "
2127                f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, "
2128                f"and pulse {i} is at "
2129                f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s."
2130            )
2131        if pulse_times_cycles[-1] > self._max_time_cycles():
2132            raise PulsePalError(
2133                f"{context}: the last pulse starts at {self._cycles_to_seconds(pulse_times_cycles[-1])} s. "
2134                f"Pulse times must be at most {self.info.max_time} s."
2135            )
2136
2137        if self.info.firmware_version > 21:
2138            header = bytes([self._OP_MENU_BYTE, 95, train_id - 1])
2139        else:
2140            header = bytes([self._OP_MENU_BYTE, 74 + train_id])
2141        self._write_raw(
2142            header + struct.pack("<I", n_pulses)
2143            + np.asarray(pulse_times_cycles).astype("<u4").tobytes()
2144            + np.asarray(pulse_voltage_bits).astype("<u2").tobytes()
2145        )
2146        self._read_ack(context)
2147
2148    def _number_array(self, values, name, description):
2149        """Return values (a scalar, a list, a tuple or a 1-D NumPy array) as a float64 array, refusing booleans,
2150        strings and anything else that is not a number, as the scalar conversions do.
2151
2152        An array of two or more dimensions, or a list of lists, is refused rather than flattened: one value per
2153        pulse is a list, and a 2-D one is a mistake, such as both rows of a stereo waveform. (The MATLAB class, which
2154        took a matrix's longest side as its pulse count and sent all of it, sent the rest to the device as commands.)
2155        """
2156        if isinstance(values, np.ndarray):
2157            self._check_one_dimensional(values, name)
2158            if values.dtype.kind in "iuf":
2159                return values.ravel().astype(float)
2160        items = self._as_list(values)
2161        refused_types = tuple(kind for kind in {type(item) for item in items}  # Each type once: fast for long lists
2162                              if issubclass(kind, (bool, np.bool_, str, bytes)))
2163        if refused_types:
2164            item = next(item for item in items if isinstance(item, refused_types))
2165            raise PulsePalError(f"{name} must be {description}. Received {item!r}.")
2166        try:
2167            array = np.asarray(items, dtype=float)
2168        except (TypeError, ValueError):
2169            for item in items:
2170                try:
2171                    float(item)
2172                except (TypeError, ValueError):
2173                    raise PulsePalError(f"{name} must be {description}. Received {item!r}.") from None
2174            raise
2175        self._check_one_dimensional(array, name)  # A list of lists
2176        return array
2177
2178    @staticmethod
2179    def _check_one_dimensional(array, name):
2180        if array.ndim > 1:
2181            raise PulsePalError(
2182                f"{name} must be one-dimensional, one value per pulse: a list, a tuple or a 1-D NumPy array. "
2183                f"Received an array of shape {array.shape}."
2184            )
2185
2186    @staticmethod
2187    def _first(values, refused):
2188        """The first value at a True position of the boolean array `refused`, for an error message."""
2189        i = int(np.flatnonzero(refused)[0])
2190        item = values.ravel()[i] if isinstance(values, np.ndarray) else PulsePalDevice._as_list(values)[i]
2191        return item.item() if isinstance(item, np.generic) else item
2192
2193    def _custom_times_to_cycles(self, values, name):
2194        """Convert custom pulse times to timer cycles, all at once: _custom_time_cycles() for each of them.
2195
2196        Each must be a time the device can play, and a whole number of DeviceInfo.min_pulse_width_us to the nearest
2197        microsecond, as in the MATLAB class. Returns an int64 array.
2198        """
2199        seconds = self._number_array(values, name, "a time of 0 s or more")
2200        refused = ~(np.isfinite(seconds) & (seconds >= 0))
2201        if refused.any():
2202            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {self._first(values, refused)!r}.")
2203        # The same arithmetic as _seconds_to_cycles() and _custom_time_cycles(): NumPy's round() also takes a
2204        # halfway value to the even integer
2205        refused = np.round(seconds * float(self.info.cycle_frequency)) > self._max_time_cycles()
2206        if refused.any():
2207            raise PulsePalError(
2208                f"{name} must be at most {self.info.max_time} s, the longest time the device's screen "
2209                f"shows. Received {self._first(values, refused)!r}."
2210            )
2211        microseconds = np.round(seconds * 1e6)
2212        step_us = int(self.info.min_pulse_width_us)
2213        refused = np.mod(microseconds, step_us) != 0
2214        if refused.any():
2215            raise PulsePalError(
2216                f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). "
2217                f"Received {self._first(values, refused)!r}."
2218            )
2219        return (microseconds // int(self.info.cycle_period_us)).astype(np.int64)
2220
2221    def _volts_array_to_bits(self, values, name):
2222        """Convert voltages to DAC codes, all at once: _volts_to_bits() for each of them. Returns an int64 array."""
2223        volts = self._number_array(values, name, "in [-10, 10] V")
2224        refused = ~((volts >= -10) & (volts <= 10))  # Also refuses NaN
2225        if refused.any():
2226            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {self._first(values, refused)!r}.")
2227        return np.round((volts + 10) / 20 * self._DAC_BITMAX).astype(np.int64)
2228
2229    # ------------------------------------------------------------------
2230    # Internals: serial port and units
2231    # ------------------------------------------------------------------
2232
2233    def _write_serial(self, *args):
2234        """Write one or more data/type pairs to the serial port, in one write."""
2235        if len(args) % 2 != 0:
2236            raise PulsePalError(
2237                "Serial writes require data/type argument pairs."
2238            )
2239
2240        payload = bytearray()
2241        for i in range(0, len(args), 2):
2242            payload.extend(self._pack_values(args[i], args[i + 1]))
2243        self._write_raw(bytes(payload))
2244
2245    def _write_raw(self, payload):
2246        """Write a command to the serial port, in one write.
2247
2248        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
2249        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it put every reply
2250        after it out of step: status() reported channels playing that were not, and assignments the device took
2251        raised refusals. So it is discarded first (_discard_lost_reply()). Then this command's reply, if it has one,
2252        is pending until it has been read: _read_raw() marks it read, and a method that reads a reply otherwise
2253        (format_microsd()) sets _reply_pending back to None itself.
2254        """
2255        if self._reply_pending is not None:
2256            self._discard_lost_reply()
2257        if payload[1] not in _OPS_WITHOUT_TRACKED_REPLY:
2258            self._reply_pending = payload[1]  # Before the write: a Ctrl-C can come as soon as it returns
2259        bytes_written = self.port.write(payload)
2260        if bytes_written != len(payload):
2261            raise PulsePalError(
2262                f"Wrote {bytes_written} byte(s), expected to write "
2263                f"{len(payload)} byte(s)."
2264            )
2265
2266    def _discard_lost_reply(self):
2267        """Discard what arrives of a reply that was not read in full (see _write_raw()), and bring this object back
2268        in step with the device.
2269
2270        The command may have changed the parameters, unknown to this object: an assignment interrupted after its
2271        write, for one, which the device took and this object did not. So after one of _PARAMETER_OPS, they are read
2272        back (op 93), on firmware v22 or newer and outside param sync mode, where op 93 returns the parameters playing
2273        rather than a set stored for the next sync edge.
2274        """
2275        op = self._reply_pending
2276        self._reply_pending = None
2277        n_late = len(_common.read_until_quiet(self.port))
2278        read_back = (op in _PARAMETER_OPS and self.info.firmware_version > 21
2279                     and "Param Sync" not in list(self._trigger_mode)[1:])
2280        _common.warn_lost_reply(f"op {op}", n_late,
2281                                ", and the parameters were read back from the device" if read_back else "")
2282        if read_back:
2283            try:
2284                self.sync_from_device()
2285            except PulsePalError as exc:
2286                raise PulsePalError(
2287                    f"Pulse Pal did not send its parameters, read back after the reply to an earlier command was "
2288                    f"lost. {_common.NO_REPLY_HINT}"
2289                ) from exc
2290
2291    def _read_raw(self, n_bytes):
2292        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
2293        longer pending (see _write_raw()). Only connecting reads a reply in more than one call."""
2294        message_bytes = self.port.read(n_bytes)
2295        if len(message_bytes) < n_bytes:
2296            raise PulsePalError(
2297                f"Pulse Pal sent {len(message_bytes)} of the {n_bytes} byte(s) of its reply within "
2298                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
2299            )
2300        self._reply_pending = None
2301        return message_bytes
2302
2303    def _read_serial(self, n_values, datatype):
2304        """Read values from the serial port and unpack them with struct."""
2305        fmt = self._STRUCT_FORMATS[datatype]
2306        n_values = int(n_values)
2307        n_bytes = n_values * struct.calcsize(fmt)
2308        message_bytes = self._read_raw(n_bytes)
2309
2310        values = struct.unpack(
2311            f"{self._ENDIANNESS}{n_values}{fmt}",
2312            message_bytes,
2313        )
2314        if n_values == 1:
2315            return values[0]
2316        return list(values)
2317
2318    def _read_ack(self, context, on_refusal=None, refusal_message=None, refusal_messages=None):
2319        """Read a one-byte acknowledgement from the device.
2320
2321        The device replies 1 if it executed the command, or 0 if it rejected
2322        the command because a value was out of range. On a 0, on_refusal()
2323        is called before the error is raised: the device resets a value it
2324        refuses, so callers use it to read the value back into the local copy.
2325        refusal_message replaces the error's usual message, for a command
2326        refused for another reason. refusal_messages maps other replies to
2327        their own message, e.g. op 90's 2.
2328        """
2329        try:
2330            acknowledgement = self._read_serial(1, "uint8")
2331        except PulsePalError as exc:
2332            raise PulsePalError(
2333                f"Pulse Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
2334                f"{_common.NO_REPLY_HINT}"
2335            ) from exc
2336        if acknowledgement != 1:
2337            refusal = PulsePalError(
2338                (refusal_messages or {}).get(acknowledgement) or
2339                refusal_message or
2340                f"Pulse Pal rejected the command sent by {context}. "
2341                "This usually means that a channel number, parameter code or "
2342                "value was out of range for the connected device."
2343            )
2344            if on_refusal is not None:
2345                try:
2346                    on_refusal()
2347                except PulsePalError as exc:
2348                    raise refusal from exc
2349            raise refusal
2350
2351    def _pack_values(self, values, datatype):
2352        """Pack scalar, list/tuple, or NumPy array values into bytes."""
2353        fmt = self._STRUCT_FORMATS[datatype]
2354        values_list = self._as_list(values)
2355        min_value, max_value = self._TYPE_RANGES[datatype]
2356        normalized = []
2357        for value in values_list:
2358            value = int(value)
2359            if not min_value <= value <= max_value:
2360                raise PulsePalError(
2361                    f"Value {value} is out of range for {datatype} "
2362                    f"({min_value} to {max_value})."
2363                )
2364            normalized.append(value)
2365        return struct.pack(f"{self._ENDIANNESS}{len(normalized)}{fmt}", *normalized)
2366
2367    @staticmethod
2368    def _as_list(values):
2369        """Return values as a flat list, wrapping scalars in one."""
2370        if isinstance(values, np.ndarray):
2371            return values.ravel().tolist()
2372        if isinstance(values, (bytes, bytearray, str)):
2373            return [values]
2374        if isinstance(values, numbers.Number):
2375            return [values]
2376        try:
2377            return list(values)
2378        except TypeError:
2379            return [values]
2380
2381    def _volts_to_bits(self, value, name="voltage"):
2382        """Convert -10 V to +10 V to the corresponding DAC code.
2383
2384        Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do.
2385        Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would
2386        play a different voltage.
2387        """
2388        try:
2389            volts = float(value)
2390        except (TypeError, ValueError):
2391            volts = float("nan")
2392        if isinstance(value, (bool, np.bool_, str)) or not -10 <= volts <= 10:  # Also refuses NaN
2393            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.")
2394        return int(round((volts + 10) / 20 * self._DAC_BITMAX))
2395
2396    def _bits_to_volts(self, value):
2397        """Convert a DAC code to volts, snapping to a clean value within 1 LSB."""
2398        raw_volts = (float(value) / self._DAC_BITMAX * 20) - 10
2399        lsb_volts = 20.0 / self._DAC_BITMAX
2400        # The nearest 3 decimal number (e.g. 5.000, 4.255), if the code is within 1 LSB of it
2401        clean_volts = round(raw_volts, 3)
2402        if abs(raw_volts - clean_volts) <= lsb_volts:
2403            return clean_volts
2404        return round(raw_volts, 4)
2405
2406    def _seconds_to_cycles(self, value, name="time"):
2407        """Convert seconds to the corresponding refresh-cycle count.
2408
2409        Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do:
2410        125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is
2411        negative, not a number, or longer than DeviceInfo.max_time.
2412        """
2413        try:
2414            seconds = float(value)
2415        except (TypeError, ValueError):
2416            seconds = float("nan")
2417        if isinstance(value, (bool, np.bool_, str)) or not (math.isfinite(seconds) and seconds >= 0):
2418            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.")
2419        cycles = int(round(seconds * float(self.info.cycle_frequency)))
2420        if cycles > self._max_time_cycles():
2421            raise PulsePalError(
2422                f"{name} must be at most {self.info.max_time} s, the longest time the device's screen "
2423                f"shows. Received {value!r}."
2424            )
2425        return cycles
2426
2427    def _max_time_cycles(self):
2428        """DeviceInfo.max_time in timer cycles: 999999990 on a 10 us timer, 199999998 on a 50 us timer."""
2429        return int(round(self.info.max_time * float(self.info.cycle_frequency)))
2430
2431    def _custom_time_cycles(self, value, name):
2432        """Convert a custom pulse time or sampling period to timer cycles.
2433
2434        It must be a whole number of DeviceInfo.min_pulse_width_us, to the nearest microsecond,
2435        as in the MATLAB class. A time between two steps is refused, not rounded
2436        as the time parameters are, so that a train plays as it was written.
2437        """
2438        self._seconds_to_cycles(value, name)  # Checks that it is a time the device can play
2439        microseconds = int(round(float(value) * 1e6))
2440        step_us = int(self.info.min_pulse_width_us)
2441        if microseconds % step_us:
2442            raise PulsePalError(
2443                f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). Received {value!r}."
2444            )
2445        return microseconds // int(self.info.cycle_period_us)
2446
2447    def _cycles_to_seconds(self, value):
2448        """Convert hardware timer cycle counts to seconds."""
2449        return float(value) / float(self.info.cycle_frequency)
2450
2451
2452def _apply_method(code):
2453    def apply(self, values):
2454        return self._apply_output_param(code, values)
2455    return apply
2456
2457
2458# One _apply_<name> method per output parameter, which its ChannelSettings list calls
2459for _code, (_name, _kind) in _OUTPUT_PARAMETERS.items():
2460    setattr(PulsePalDevice, f"_apply_{_name}", _apply_method(_code))
2461del _code, _name, _kind
class PulsePalDevice:
 399class PulsePalDevice:
 400    """A class to control a Pulse Pal device on a USB serial port.
 401
 402    Creating an instance opens the serial port, exchanges a handshake
 403    with the device, verifies that its firmware is supported, reads the
 404    device properties into `PulsePalDevice.info`, and programs the
 405    device with the default parameters (see
 406    `PulsePalDevice.set_default_params`).
 407
 408    ```python
 409    from pulsepal import PulsePalDevice
 410
 411    P = PulsePalDevice("COM3")
 412    P.phase1_voltage[1] = 5
 413    P.trigger(1)
 414    P.close()
 415    ```
 416
 417    Here, replace "COM3" with Pulse Pal's USB serial port name.
 418    To view a list of available ports, use
 419    `PulsePalDevice.serialportlist()`.
 420    Pulse Pal's port may not be visible if it is connected to another
 421    instance of PulsePalDevice or an external application. Use
 422    `PulsePalDevice.serialportlist("all")` to view all ports.
 423    If you see multiple available ports, disconnect Pulse Pal's USB plug
 424    and run serialportlist() again. Notice which port disappears from the
 425    list.
 426
 427    PulsePalDevice is also a context manager, which closes the connection on
 428    exit even if an error is raised:
 429
 430    ```python
 431    with PulsePalDevice("COM3") as P:
 432        P.trigger(1)
 433    ```
 434
 435    The attributes below named after Pulse Pal parameters are lists
 436    indexed by channel number, with index 0 unused (see "Parameters"
 437    above). `print(P)` shows them all, and `PulsePalDevice.export_params`
 438    returns them as a dict.
 439    """
 440
 441    port: "serial.Serial"
 442    """The open `serial.Serial` port connected to the device."""
 443
 444    info: DeviceInfo
 445    """Properties of the connected device. See `DeviceInfo`."""
 446
 447    is_biphasic = _channel_setting("is_biphasic", """Pulse shape per channel: `False` for monophasic, `True` for biphasic.
 448
 449    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 450    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 451    phase 2.
 452    """)
 453
 454    phase1_voltage = _channel_setting("phase1_voltage", """Voltage of the first phase of each pulse, in volts [-10, 10].""")
 455
 456    phase2_voltage = _channel_setting("phase2_voltage", """Voltage of the second phase of each pulse, in volts [-10, 10].
 457
 458    Used only when `PulsePalDevice.is_biphasic` is `True` for the channel.
 459    """)
 460
 461    resting_voltage = _channel_setting("resting_voltage", """Voltage held between pulses, in volts [-10, 10].
 462
 463    A new resting voltage reaches an idle channel's output at once. A
 464    channel playing a pulse train keeps playing it, and moves to the new
 465    resting voltage at its next transition to rest.
 466    """)
 467
 468    phase1_duration = _channel_setting("phase1_duration", """Duration of the first phase of each pulse, in seconds.
 469
 470    At least `DeviceInfo.min_pulse_width_us`.
 471    """)
 472
 473    inter_phase_interval = _channel_setting("inter_phase_interval", """Interval between the two phases of a biphasic pulse, in seconds.
 474
 475    The channel rests at `PulsePalDevice.resting_voltage` during the
 476    interval. Used only when `PulsePalDevice.is_biphasic` is `True`.
 477    """)
 478
 479    phase2_duration = _channel_setting("phase2_duration", """Duration of the second phase of each pulse, in seconds.
 480
 481    Used only when `PulsePalDevice.is_biphasic` is `True` for the channel.
 482    At least `DeviceInfo.min_pulse_width_us`.
 483    """)
 484
 485    inter_pulse_interval = _channel_setting("inter_pulse_interval", """Interval from the end of one pulse to the start of the next, in
 486    seconds. At least `DeviceInfo.min_pulse_width_us`.
 487
 488    It is not the pulse period: 1 ms pulses 49 ms apart play 20 pulses per
 489    second.
 490    """)
 491
 492    burst_duration = _channel_setting("burst_duration", """Duration of each burst of pulses, in seconds.
 493
 494    Set to `0` to disable bursts, so that pulses continue for the whole
 495    pulse train. A pulse starts only if it ends before the burst does:
 496    its first phase, or for a biphasic pulse the whole pulse, so that
 497    the end of a burst never cuts off a second phase.
 498    """)
 499
 500    inter_burst_interval = _channel_setting("inter_burst_interval", """Interval between bursts of pulses, in seconds.
 501
 502    The channel rests at `PulsePalDevice.resting_voltage` between
 503    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 504    """)
 505
 506    pulse_train_duration = _channel_setting("pulse_train_duration", """Total duration of the pulse train, in seconds.
 507
 508    At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts
 509    short a monophasic pulse still playing. A biphasic pulse starts only
 510    if it can end by the end of the train, so that it keeps its second
 511    phase. A train's end is set when it starts: a new duration applies
 512    from the next train.
 513    """)
 514
 515    pulse_train_delay = _channel_setting("pulse_train_delay", """Delay from the trigger to the onset of the pulse train, in
 516    seconds.""")
 517
 518    link_trigger_channel1 = _channel_setting("link_trigger_channel1", """Whether each output channel is triggered by trigger channel 1.
 519
 520    `True` links the output channel to trigger channel 1, `False` unlinks
 521    it. By default no output channel is linked to either trigger channel,
 522    so a TTL starts nothing until a script links it.
 523    """)
 524
 525    link_trigger_channel2 = _channel_setting("link_trigger_channel2", """Whether each output channel is triggered by trigger channel 2.
 526
 527    `True` links the output channel to trigger channel 2, `False` unlinks
 528    it.
 529    """)
 530
 531    custom_train_id = _channel_setting("custom_train_id", """Custom pulse train played by each output channel.
 532
 533    `0` plays the parametrically defined train. `1` or higher plays the
 534    matching custom train, previously loaded with
 535    `PulsePalDevice.send_custom_pulse_train` or
 536    `PulsePalDevice.send_custom_waveform`: 1-4 on Pulse Pal 3, 1-2 on
 537    Pulse Pal 2 (`DeviceInfo.n_custom_pulse_trains`).
 538    """)
 539
 540    custom_train_target = _channel_setting("custom_train_target", """What the times of a custom train mark.
 541
 542    `"Pulses"` if each time is the onset of a pulse, `"Bursts"` if each
 543    time is the onset of a burst of pulses, which lasts
 544    `PulsePalDevice.burst_duration`. A burst whose time comes while the
 545    burst before plays is dropped, so that every burst played is on
 546    time. `"Bursts"` needs a burst duration above 0: with
 547    `PulsePalDevice.auto_sync` on, set the burst duration first, or set
 548    both in a `PulsePalDevice.batch` block.
 549    """)
 550
 551    custom_train_loop = _channel_setting("custom_train_loop", """Whether a custom train repeats.
 552
 553    `True` loops the custom train until
 554    `PulsePalDevice.pulse_train_duration` has elapsed, `False` plays it
 555    once. Each pass starts as the last pulse (or burst, for a train
 556    targeting bursts) of the pass before ends.
 557    `PulsePalDevice.continuous_loop` does not apply to custom trains.
 558    """)
 559
 560    continuous_loop = _channel_setting("continuous_loop", """Whether each output channel plays its pulse train until stopped.
 561
 562    `False` plays the pulse train once per trigger, for
 563    `PulsePalDevice.pulse_train_duration`. `True` plays it until the
 564    channel is stopped, ignoring `PulsePalDevice.pulse_train_duration`.
 565    For parametric trains only: a custom train ignores it, and plays as
 566    `PulsePalDevice.custom_train_loop` says. Requires firmware v22 or
 567    newer. The device does not report it, so
 568    `PulsePalDevice.sync_from_device` leaves it as it is.
 569    """)
 570
 571    trigger_mode = _channel_setting("trigger_mode", """Response of each trigger channel to an incoming TTL pulse.
 572
 573    Indexed by trigger channel number, 1 or 2 (index 0 is unused). The
 574    modes, also listed in `DeviceInfo.trigger_modes`, are:
 575
 576    - `"Normal"`: a rising edge starts the pulse trains of the linked
 577      output channels, and edges during a train are ignored.
 578    - `"Toggle"`: as `"Normal"`, but a rising edge during a train stops
 579      it.
 580    - `"Gated"`: the trains play only while the trigger TTL is high.
 581    - `"Param Sync"` (Pulse Pal 3 only): a TTL rising edge starts and
 582      stops nothing. It loads the parameters most recently sent by
 583      `PulsePalDevice.sync_to_device` (or at the end of a
 584      `PulsePalDevice.batch` block), and the custom trains loaded since
 585      the previous edge. This is how the next trial's parameters and
 586      custom trains are sent during the current trial and applied the
 587      instant it starts.
 588
 589    The integer codes 0-3 are accepted too.
 590
 591    An output channel that is idle at a param sync edge takes its new
 592    parameters in the timer cycle the edge is detected. One that is
 593    playing a pulse train finishes that train on the parameters it
 594    started with, and takes the new ones the moment it ends, so a train
 595    that runs past the end of a trial keeps one shape throughout and the
 596    next trigger plays a whole train with the new parameters. A channel
 597    in continuous loop mode has no train end, so it keeps its parameters
 598    until something stops it.
 599
 600    While either trigger channel is in param sync mode, **only
 601    `PulsePalDevice.sync_to_device` (and the end of a
 602    `PulsePalDevice.batch` block) and the custom train loads are held
 603    back**: with `PulsePalDevice.auto_sync` on, every assignment still
 604    programs the device immediately. So leaving param sync mode means
 605    assigning `trigger_mode` with `auto_sync` on; a trigger mode sent by
 606    `PulsePalDevice.sync_to_device` does not take effect until a sync
 607    edge arrives. A custom train loaded in param sync mode
 608    (`PulsePalDevice.send_custom_pulse_train`) replaces the old one at
 609    the edge: channels playing the old one at the edge finish their
 610    trains on it. Leaving param sync mode discards parameters still
 611    waiting for an edge, but makes a custom train still waiting current
 612    at once.
 613
 614    A param sync channel's links to output channels are ignored. To
 615    start a pulse train on the same edge, wire the TTL to the other
 616    trigger channel as well: both edges arrive in the same timer cycle,
 617    and the parameters are loaded first.
 618
 619    Connecting a new `PulsePalDevice`, and
 620    `PulsePalDevice.set_default_params`, take both trigger channels out
 621    of param sync mode, so that the default parameters reach the device
 622    instead of waiting for a TTL.
 623
 624    ```python
 625    P.trigger_mode[2] = "Param Sync"   # sent at once
 626    with P.batch():
 627        P.phase1_voltage[1] = 2.5
 628        P.phase1_duration[1] = 0.002
 629    # Stored by the device: the next rising edge on trigger channel 2 applies it
 630    ```
 631    """)
 632
 633    _CURRENT_FIRMWARE_VERSION = 22
 634    _OLDEST_FIRMWARE_SUPPORTED = 21
 635    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 636
 637    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 638    _HANDSHAKE_OPCODE = _common.OP_HANDSHAKE
 639    _HANDSHAKE_DEVICE_CYCLES_OPCODE = _common.OP_HANDSHAKE_DEVICE_CYCLES
 640    _HANDSHAKE_DEVICE_CYCLES_TIMEOUT = 0.3  # Seconds to wait for a reply to op 103 before trying op 72
 641    _HANDSHAKE_RESPONSE = 75  # 'K'
 642    _DAC_BITMAX = 65535
 643    _PARAM_MESSAGE_BYTES = 178  # Length of the parameter set sent by op 93
 644    _STATUS_MESSAGE_BYTES = 29  # Length of the reply to op 101: playing channels, then 7 uint32 counters
 645    # Phase 1 and 2 durations, inter-pulse interval and train duration last at least this many
 646    # timer cycles, by hardware version, as in the MATLAB and C++ classes and the joystick menu.
 647    # Pulse Pal 2's trigger channels read their inputs once per cycle, so a one cycle pulse from
 648    # another Pulse Pal could fall between two reads. Pulse Pal 3's also latch every edge.
 649    _MIN_PULSE_CYCLES = {2: 2, 3: 1}
 650
 651    _ENDIANNESS = "<"
 652    _STRUCT_FORMATS = {
 653        "uint8": "B",
 654        "int8": "b",
 655        "uint16": "H",
 656        "int16": "h",
 657        "uint32": "I",
 658        "int32": "i",
 659    }
 660    _TYPE_RANGES = {
 661        "uint8": (0, 2**8 - 1),
 662        "int8": (-(2**7), 2**7 - 1),
 663        "uint16": (0, 2**16 - 1),
 664        "int16": (-(2**15), 2**15 - 1),
 665        "uint32": (0, 2**32 - 1),
 666        "int32": (-(2**31), 2**31 - 1),
 667    }
 668
 669    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_raw())
 670    _unsent = frozenset()  # The settings assigned with auto_sync off since the parameters were last sent or read
 671
 672    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 673        """Open a connection to a Pulse Pal device.
 674
 675        Opens the serial port, exchanges the handshake, verifies the
 676        firmware version, reads the device properties into
 677        `PulsePalDevice.info`, and programs the device with the default
 678        parameters.
 679
 680        ```python
 681        P = PulsePalDevice("COM3")
 682        P = PulsePalDevice(serial_number="20345420")  # Wherever it is plugged in
 683        ```
 684
 685        Args:
 686            port_name: USB serial port for the Pulse Pal device, such as
 687                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 688            serial_number: Instead of `port_name`, the device's USB serial
 689                number (`DeviceInfo.serial_number`), which finds its port.
 690                Pulse Pal 3 only: Pulse Pal 2 has none.
 691            timeout: Serial read timeout, in seconds.
 692
 693        Firmware newer than this module knows (`v22`) is used with a
 694        warning: new firmware only adds commands, so everything here still
 695        works with it. Update the package to use what is new.
 696
 697        Raises:
 698            PulsePalError: If the device does not return the expected
 699                handshake (for example, it runs Wave Pal or Synth Pal
 700                firmware), or its firmware is older than v21, or no port
 701                has the serial number given. The port is closed again
 702                before any error is raised.
 703            serial.SerialException: If the serial port cannot be opened,
 704                e.g. because another program has it open (on Linux and
 705                macOS, another session of these classes, which lock it).
 706        """
 707        self._closed = True
 708        self._gui = None
 709        self._auto_sync = True
 710        self.info = DeviceInfo()
 711        self._create_settings()
 712        port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice")
 713        self.port = serial.Serial(
 714            port_name,
 715            self._BAUD_RATE,
 716            timeout=timeout,
 717            rtscts=True,
 718            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 719            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 720            # it, such as another of these classes, gets an error instead
 721            exclusive=True,
 722        )
 723        self._closed = False
 724        try:
 725            self._start_session(port_name)
 726        except BaseException:
 727            # Otherwise the port stays open until the object is garbage
 728            # collected, and a second attempt, or WavePalDevice, cannot open
 729            # it. The device is in an unknown state, so it is not sent the
 730            # disconnect op.
 731            self._close(send_disconnect=False)
 732            raise
 733
 734    def _create_settings(self):
 735        """Create the parameter lists, holding the default values (nothing is sent)."""
 736        for code, (name, _) in _OUTPUT_PARAMETERS.items():
 737            setattr(self, "_" + name, ChannelSettings(
 738                name, [_DEFAULT_OUTPUT_VALUES[name]] * 4, self, f"_apply_{name}"))
 739        self._trigger_mode = ChannelSettings(
 740            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 741
 742    def _start_session(self, port_name):
 743        """Handshake, check the firmware, and program the defaults."""
 744        # Discard anything left unread by an earlier session (one stopped mid-reply, for example):
 745        # it would be read as the handshake's reply. A reply the device sends after this, to a
 746        # command it had not reached yet, is skipped by read_handshake_reply().
 747        self.port.reset_input_buffer()
 748        # Op 103: the session's times are in the device's own timer cycles (10 us on Pulse Pal 3). Firmware from before
 749        # October 2026, Wave Pal and Synth Pal ignore it, and answer op 72, whose times are in 50 us cycles. A device that
 750        # answers op 103 too late counts the session's times in 50 us cycles too, after the op 72 that follows; either
 751        # way op 94 reports the session's unit, which the class then uses.
 752        self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_DEVICE_CYCLES_OPCODE), "uint8")
 753        reply = _common.read_handshake_reply(self.port, timeout=self._HANDSHAKE_DEVICE_CYCLES_TIMEOUT)
 754        if reply is None:
 755            self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), "uint8")
 756            reply = _common.read_handshake_reply(self.port)
 757        if reply is None:
 758            raise PulsePalError(
 759                f"No reply to the handshake from the device on {port_name}. Is it a Pulse Pal? "
 760                f"{_common.NO_REPLY_HINT}"
 761            )
 762        handshake, firmware_version = reply
 763        if handshake != self._HANDSHAKE_RESPONSE:
 764            if handshake in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
 765                raise _common.other_firmware_error(handshake, firmware_version, port_name, "Pulse Pal")
 766            raise PulsePalError(
 767                "Incorrect handshake returned. Expected "
 768                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 769            )
 770
 771        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 772            raise PulsePalError(
 773                "Old firmware detected, v"
 774                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 775                "newer is required."
 776            )
 777        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 778            # New firmware only adds commands (see /Firmware/PROTOCOL.md), so this class still works with it
 779            warnings.warn(
 780                f"Pulse Pal firmware v{firmware_version} is newer than this pulsepal package knows "
 781                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what "
 782                "is new.", stacklevel=3)
 783        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 784            warnings.warn(
 785                f"Pulse Pal firmware v{firmware_version} is supported, but v"
 786                f"{self._CURRENT_FIRMWARE_VERSION} is available: see /MATLAB/FirmwareLoader.", stacklevel=3)
 787        self.info.firmware_version = firmware_version
 788
 789        if self.info.firmware_version > 21:
 790            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 791            self.info.hardware_version = self._read_serial(1, "uint8")
 792            self.info.cycle_period_us = self._read_serial(1, "uint32")  # The session's time unit (see op 103 above)
 793            # Not 1 / (period / 1e6): at 10 us that is 99999.99999999999, and times read back from the device would
 794            # convert to 0.00010000000000000002 s instead of 0.0001 s
 795            self.info.cycle_frequency = 1000000 / self.info.cycle_period_us
 796            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 797            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 798            self._write_serial((self._OP_MENU_BYTE, 100), "uint8")  # Circuit board revision
 799            revision = self.port.read(1)
 800            if len(revision) < 1:
 801                raise PulsePalError(
 802                    f"The Pulse Pal on {port_name} did not report its circuit board revision."
 803                )
 804            self._reply_pending = None  # Read without _read_raw() (see _write_raw())
 805            self.info.circuit_board_revision = revision[0]
 806            self._write_serial((self._OP_MENU_BYTE, _common.OP_SERIAL_NUMBER), "uint8")
 807            self.info.serial_number = _common.read_serial_number(self._read_raw)
 808        else:
 809            self.info.hardware_version = 2
 810            self.info.circuit_board_revision = 0
 811            self.info.serial_number = ""  # v21 runs on Pulse Pal 2, which has none
 812            self.info.cycle_period_us = 50
 813            self.info.cycle_frequency = 20000
 814            self.info.n_custom_pulse_trains = 2
 815            self.info.max_custom_pulses = 5000
 816        self.info.min_pulse_width_us = (
 817            self._MIN_PULSE_CYCLES[self.info.hardware_version] * self.info.cycle_period_us
 818        )
 819        if not self._has_param_sync():
 820            self.info.trigger_modes = TRIGGER_MODES[:3]
 821
 822        # Client name op + "PYTHON" in ASCII.
 823        self._write_serial(
 824            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 825            "uint8",
 826        )
 827
 828        self.set_default_params()
 829
 830    @staticmethod
 831    def serialportlist(ports_to_list="available"):
 832        """Return the names of the USB serial ports on this computer.
 833
 834        Called on the class, without connecting to a device, to find the
 835        port name to pass to `PulsePalDevice`:
 836
 837        ```python
 838        from pulsepal import PulsePalDevice
 839
 840        ports = PulsePalDevice.serialportlist()
 841        P = PulsePalDevice(ports[0])
 842        ```
 843
 844        Args:
 845            ports_to_list: `available` to list only the ports that are
 846                not already in use, or `all` to list every USB serial
 847                port. Not case sensitive.
 848
 849        Returns:
 850            Sorted list of port names, such as `["COM3", "COM7"]` on
 851            Windows or `["/dev/ttyACM0"]` on Linux.
 852
 853        Raises:
 854            PulsePalError: If `ports_to_list` is not `available` or
 855                `all`.
 856        """
 857        return _common.serialportlist(ports_to_list)
 858
 859    # ------------------------------------------------------------------
 860    # Programming the device
 861    # ------------------------------------------------------------------
 862
 863    @property
 864    def auto_sync(self):
 865        """Whether assigning a parameter programs the device at once.
 866
 867        `True` (the default): each assignment, such as
 868        `P.phase1_voltage[1] = 5`, programs the device at once, also while
 869        a trigger channel is in param sync mode. It sends the channels
 870        whose value it changes, and nothing for a channel that already
 871        holds the value assigned: the other channels keep what the device
 872        holds, e.g. values set with its joystick (read them with
 873        `PulsePalDevice.sync_from_device`).
 874
 875        `False`: assignments change only this object's copy of the
 876        parameters, checking each value, and
 877        `PulsePalDevice.sync_to_device` sends all of them in one command.
 878        Turning it back on does not send changes made meanwhile, so it
 879        raises `pulsepal.PulsePalError` while there are any: call
 880        `PulsePalDevice.sync_to_device` first to send them, or
 881        `PulsePalDevice.sync_from_device` to discard them. A
 882        `PulsePalDevice.batch` block does it all for you. `close()` warns of
 883        changes never sent.
 884        """
 885        return self._auto_sync
 886
 887    @auto_sync.setter
 888    def auto_sync(self, value):
 889        value = to_bool(value, "auto_sync")
 890        if value and self._unsent:
 891            # Turned on, auto_sync would leave them unsent, and the device playing its old parameters
 892            discard = "sync_from_device() to discard them"
 893            if "continuous_loop" in self._unsent:
 894                discard += " (all but continuous_loop, which the device does not report)"
 895            raise PulsePalError(
 896                f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call "
 897                f"sync_to_device() to send them, or {discard}, before turning auto_sync on."
 898            )
 899        self._auto_sync = value
 900
 901    def batch(self):
 902        """Change several parameters, and send them in one command.
 903
 904        Returns a context manager. In its block, assignments change only
 905        this object's copy of the parameters (as with
 906        `PulsePalDevice.auto_sync` off), and when the block ends,
 907        `PulsePalDevice.sync_to_device` sends all of them at once. If the
 908        block raises an error, nothing is sent, and the parameters go
 909        back to what they were.
 910
 911        ```python
 912        with P.batch():
 913            P.phase1_voltage = [5, 5, 2.5, 2.5]
 914            P.phase1_duration[3] = 0.002
 915        ```
 916
 917        In param sync mode, the device stores the parameters for the next
 918        rising edge on the param sync channel (see
 919        `PulsePalDevice.trigger_mode`).
 920        """
 921        return _common.batch(self)
 922
 923    def sync_to_device(self):
 924        """Program the device with this object's copy of all parameters.
 925
 926        Sends every output and trigger parameter to the device in a single
 927        command. Use it after assignments made with
 928        `PulsePalDevice.auto_sync` off; with it on, they have already
 929        reached the device.
 930
 931        ```python
 932        P.auto_sync = False
 933        P.phase1_voltage[1:5] = [5] * 4
 934        P.sync_to_device()
 935        ```
 936
 937        On Pulse Pal 3, if either trigger channel is in param sync mode
 938        (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores
 939        the parameters instead of programming them, and loads them on the
 940        next rising edge of that channel. The device still checks every
 941        value. This is the only method whose effect is deferred that way.
 942
 943        Raises:
 944            PulsePalError: If a value is out of range for its parameter
 945                (nothing is sent), or the device does not acknowledge the
 946                command.
 947        """
 948        self._check_sync()
 949        self._send_sync()
 950
 951    def sync_from_device(self):
 952        """Read all parameters from the device into this object's copy.
 953
 954        Overwrites the parameter lists with the program currently on the
 955        device, e.g. after it has been reprogrammed from its thumb
 956        joystick. `PulsePalDevice.continuous_loop` is left as it is: the
 957        device does not report it. Changes made with
 958        `PulsePalDevice.auto_sync` off and not yet sent are discarded, but
 959        for `PulsePalDevice.continuous_loop`.
 960
 961        Requires firmware v22 or newer.
 962
 963        Raises:
 964            PulsePalError: If the connected firmware is older than v22,
 965                or the device does not return the full parameter set.
 966        """
 967        self._require_firmware(22, "sync_from_device()")
 968        for name, values in self._read_device_params().items():
 969            getattr(self, name)._store(values)
 970        self._unsent = self._unsent & {"continuous_loop"}  # Not read: a change to it is still unsent
 971
 972    def set_default_params(self):
 973        """Program the device with the default parameters.
 974
 975        The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart
 976        (about 909 a second), for 1 second, resting at 0 V, on all four
 977        output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms
 978        after 0.1 ms at rest. No output channel is linked to a trigger
 979        channel, so a TTL starts nothing until a script links it, and both
 980        trigger channels are in normal mode. They are the device's own
 981        defaults, which it also loads at startup, after a comm failure
 982        and after formatting its microSD card. They are sent at once,
 983        also while `PulsePalDevice.auto_sync` is off. The constructor
 984        calls it.
 985        """
 986        for name, value in _DEFAULT_OUTPUT_VALUES.items():
 987            getattr(self, name)._store([value] * 4)
 988        self._trigger_mode._store(["Normal"] * 2)
 989        if self._has_param_sync():
 990            # A device left in param sync mode would store the sync below instead of running it,
 991            # leaving the device on its old program until a TTL arrived. A trigger mode sent on
 992            # its own is not deferred that way, so both trigger channels leave param sync mode
 993            # first. See PulsePalDevice.trigger_mode.
 994            self._send_trigger_modes(["Normal"] * 2, "set_default_params()")
 995        self._send_sync()
 996
 997    def export_params(self):
 998        """Return every parameter, as a dict of plain lists.
 999
1000        Keyed by parameter name (`DeviceInfo.output_parameter_names`, then
1001        `"trigger_mode"`), with one value per channel and no unused index
1002        0. It holds only numbers, booleans and names, so it can be saved
1003        with `json` and logged with your data, to record exactly what the
1004        device played. `PulsePalDevice.import_params` programs it again.
1005
1006        ```python
1007        import json
1008        with open("trial_params.json", "w") as f:
1009            json.dump(P.export_params(), f)
1010        ```
1011        """
1012        names = [*self.info.output_parameter_names, "trigger_mode"]
1013        return {name: list(getattr(self, name))[1:] for name in names}
1014
1015    def import_params(self, params):
1016        """Program the device with parameters exported by `PulsePalDevice.export_params`.
1017
1018        All of them are checked first, and then sent in one command, as
1019        at the end of a `PulsePalDevice.batch` block: in param sync mode,
1020        the device stores them for the next sync edge. Parameters missing
1021        from `params` keep their values. A name that is not a parameter
1022        is skipped with a warning, so that parameters exported by a newer
1023        version of this package, which may have more, can still be
1024        imported; if none is, an error is raised.
1025
1026        A whole program file, as the GUIs save it, is imported too: its
1027        parameters, not its custom trains (a warning says so, if it has
1028        any).
1029
1030        ```python
1031        with open("MyProgram.json") as f:
1032            P.import_params(json.load(f))
1033        ```
1034
1035        Args:
1036            params: A dict of parameter name to one value per channel (a
1037                list, tuple or NumPy array), as `export_params` returns,
1038                or a program file's contents. The MATLAB class's names
1039                (`phase1Voltage`) are accepted too, so that the parameters
1040                its `exportParams()` returns, saved with `jsonencode`, can
1041                be imported.
1042
1043        Raises:
1044            PulsePalError: If a value is invalid, or no name in `params` is
1045                a parameter. Nothing is sent, and the parameters keep the
1046                values they had.
1047        """
1048        params = _params_to_import(params)
1049        with self.batch():
1050            for name, values in params.items():
1051                setattr(self, name, values)
1052
1053    def set_fixed_voltage(self, channels, voltage):
1054        """Set output channels to a fixed voltage.
1055
1056        Each channel holds the voltage until it is set again or until a
1057        pulse train is triggered on it.
1058
1059        ```python
1060        P.set_fixed_voltage(4, 2.5)
1061        P.set_fixed_voltage([1, 3], -1)
1062        ```
1063
1064        Args:
1065            channels: Output channel number, 1-4, or several as a list,
1066                tuple or NumPy array.
1067            voltage: Voltage to set on each of them, in volts [-10, 10].
1068
1069        Raises:
1070            PulsePalError: If a channel number is not 1-4, the voltage is
1071                outside [-10, 10] (nothing is sent), or the device does not
1072                acknowledge the command.
1073        """
1074        channels = _common.channel_numbers(channels)
1075        voltage_bits = self._volts_to_bits(voltage, "voltage")
1076        for channel in channels:
1077            self._write_serial(
1078                (self._OP_MENU_BYTE, 79, channel),
1079                "uint8",
1080                voltage_bits,
1081                "uint16",
1082            )
1083            self._read_ack("set_fixed_voltage()")
1084
1085    def set_calibration(self, channel, voltage_offset):
1086        """Calibrate the zero code of an output channel.
1087
1088        The offset is added to every voltage the channel produces, to
1089        correct for DAC offset error. It is stored in the device's
1090        EEPROM and reloaded on boot, so it only needs to be set once.
1091
1092        Requires Pulse Pal hardware v3 or newer.
1093
1094        Args:
1095            channel: Output channel number, 1-4.
1096            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
1097
1098        Raises:
1099            PulsePalError: If the connected hardware is older than v3,
1100                `channel` is not 1-4, `voltage_offset` is outside
1101                [-0.1, 0.1], or the device does not acknowledge the
1102                command.
1103        """
1104        if self.info.hardware_version < 3:
1105            raise PulsePalError(
1106                "set_calibration() requires hardware v3 or newer."
1107            )
1108        if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)):
1109            raise PulsePalError("channel must be 1, 2, 3 or 4")
1110        if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1):  # Also refuses NaN
1111            raise PulsePalError(
1112                "voltage_offset for zero code calibration must be in range "
1113                "[-0.1, 0.1]"
1114            )
1115        # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it
1116        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
1117        self._write_serial(
1118            (self._OP_MENU_BYTE, 96, channel - 1),
1119            "uint8",
1120            voltage_bits,
1121            "int16",
1122        )
1123        self._read_ack("set_calibration()")
1124
1125    def set_screen_saver(self, enabled, timeout=1800):
1126        """Switch the device's screen saver on or off, and set its timeout.
1127
1128        With the screen saver on, the device dims its screen once it has
1129        been left alone for `timeout` seconds: no command from the
1130        computer, no rising edge on a trigger channel, and no joystick
1131        click or push. The next of these brings the screen back. Both
1132        settings are stored in the device's EEPROM and kept through power
1133        cycles; the screen saver can also be switched on and off from the
1134        device's joystick menu. A new device has it on, with 1800 s.
1135
1136        The device saves the settings once no channel is playing. Saving
1137        pauses its timer, usually for about 20 us but, once in about 2000
1138        changes, for tens of milliseconds, which would delay a trigger.
1139        Change the settings before an experiment rather than during one.
1140
1141        Requires firmware v22 or newer. Only Pulse Pal 3 has a screen
1142        saver: Pulse Pal 2 accepts `enabled=False` only.
1143
1144        ```python
1145        P.set_screen_saver(True, 300)  # dim after 5 minutes
1146        P.set_screen_saver(False)
1147        ```
1148
1149        Args:
1150            enabled: True (or 1) to switch the screen saver on, False (or
1151                0) to switch it off.
1152            timeout: Seconds without activity before the screen dims, a
1153                whole number from 1 to 65535. It is sent with every call,
1154                so leaving it out sets 1800 s.
1155
1156        Raises:
1157            PulsePalError: If the firmware is older than v22, the
1158                screen saver is switched on with hardware older than v3,
1159                a value is out of range, or the device does not
1160                acknowledge the command.
1161        """
1162        self._require_firmware(22, "set_screen_saver()")
1163        enabled = to_bool(enabled, "enabled")
1164        if not (
1165            isinstance(timeout, numbers.Real)
1166            and not isinstance(timeout, bool)
1167            and math.isfinite(timeout)
1168            and timeout == int(timeout)
1169            and 1 <= timeout <= 65535
1170        ):
1171            raise PulsePalError(
1172                "timeout must be a whole number of seconds from 1 to 65535"
1173            )
1174        if enabled and (self.info.hardware_version or 2) < 3:
1175            raise PulsePalError(
1176                "The screen saver requires hardware v3 or newer: Pulse Pal 2 "
1177                "accepts set_screen_saver(False) only."
1178            )
1179        self._write_serial(
1180            (self._OP_MENU_BYTE, 99, int(enabled)),
1181            "uint8",
1182            int(timeout),
1183            "uint16",
1184        )
1185        self._read_ack("set_screen_saver()")
1186
1187    # ------------------------------------------------------------------
1188    # Custom trains, playback and settings files
1189    # ------------------------------------------------------------------
1190
1191    def send_custom_pulse_train(
1192        self,
1193        custom_train_id,
1194        pulse_times,
1195        voltages,
1196    ):
1197        """Load a custom pulse train onto the device.
1198
1199        A custom pulse train is an arbitrary list of pulse onset times
1200        and voltages, replacing the parametric pulse voltage and onset
1201        timing. Set `PulsePalDevice.custom_train_id` on an output channel
1202        to play the train there. Each pulse takes the channel's own phase
1203        durations; a biphasic pulse's second phase is its voltage with the
1204        sign reversed. A biphasic pulse always plays whole, and a pulse
1205        whose time comes while it plays is dropped, so that every pulse
1206        played is on time.
1207
1208        ```python
1209        P.send_custom_pulse_train(
1210            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1211        )
1212        P.custom_train_id[1] = 2
1213        ```
1214
1215        Loading a train stops the output channels playing it, and their
1216        next trigger plays the new train. On Pulse Pal 3, while a trigger
1217        channel is in param sync mode (see `PulsePalDevice.trigger_mode`),
1218        the device stores the train instead, as it stores the parameters
1219        `PulsePalDevice.sync_to_device` sends: channels go on playing the
1220        old train, and the next rising edge on the param sync channel
1221        makes the new one current. A channel playing the old train at that
1222        edge finishes it, and plays the new one from its next trigger. So
1223        the next trial's custom trains can be loaded during the current
1224        trial. Leaving param sync mode makes a stored train current at
1225        once.
1226
1227        Args:
1228            custom_train_id: Custom train to load, from 1 to
1229                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1230                4 on Pulse Pal 3).
1231            pulse_times: Pulse onset times, in seconds, relative to the
1232                start of the train, increasing. Each is a multiple of
1233                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1234                100 us on Pulse Pal 2), from 0 to
1235                `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array.
1236            voltages: Voltage of each pulse, in volts [-10, 10]. Must be
1237                the same length as `pulse_times`.
1238
1239        Raises:
1240            PulsePalError: If `custom_train_id` is out of range,
1241                `pulse_times` or `voltages` is not one-dimensional (a 2-D
1242                array is refused, not flattened), they differ in length,
1243                there are no pulses, or more than
1244                `DeviceInfo.max_custom_pulses`, a pulse time is not a
1245                multiple of `DeviceInfo.min_pulse_width_us`, is out of
1246                range or is not later than the one before it, or the
1247                device does not acknowledge the command.
1248        """
1249        pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times")
1250        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1251        if len(pulse_times_cycles) != len(pulse_voltage_bits):
1252            raise PulsePalError(
1253                "pulse_times and voltages must be the same length."
1254            )
1255        self._send_custom_train(
1256            custom_train_id,
1257            pulse_times_cycles,
1258            pulse_voltage_bits,
1259            "send_custom_pulse_train()",
1260        )
1261
1262    def send_custom_waveform(
1263        self,
1264        custom_train_id,
1265        sampling_period,
1266        voltages,
1267    ):
1268        """Load an arbitrary waveform onto the device.
1269
1270        A convenience shorthand for
1271        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1272        adjoining pulses, so that `voltages` is played as a waveform
1273        sampled every `sampling_period` seconds. It is loaded as a custom
1274        train is: see `PulsePalDevice.send_custom_pulse_train` for what
1275        happens to channels playing it, and in param sync mode.
1276
1277        Set the channel's `PulsePalDevice.phase1_duration` to
1278        `sampling_period` as well, so that each sample is held for the
1279        sampling period.
1280
1281        ```python
1282        import math
1283
1284        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1285        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1286        P.custom_train_id[2] = 1
1287        P.phase1_duration[2] = 0.001
1288        ```
1289
1290        Args:
1291            custom_train_id: Custom train to load, from 1 to
1292                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1293                4 on Pulse Pal 3).
1294            sampling_period: In seconds: a multiple of
1295                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1296                100 us on Pulse Pal 2). Each voltage is
1297                held for this long.
1298            voltages: Waveform samples, in volts [-10, 10].
1299                Accepts a list, tuple or 1-D NumPy array.
1300
1301        Raises:
1302            PulsePalError: If `custom_train_id` is out of range, there
1303                are no samples, or more than
1304                `DeviceInfo.max_custom_pulses`, `sampling_period` is not a
1305                positive multiple of `DeviceInfo.min_pulse_width_us`, the
1306                last sample would start after `MAX_TIME`, or the device
1307                does not acknowledge the command.
1308        """
1309        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1310        period_cycles = self._custom_time_cycles(sampling_period, "sampling_period")
1311        if period_cycles == 0:
1312            raise PulsePalError(
1313                f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. "
1314                f"Received {sampling_period!r}."
1315            )
1316        pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64)
1317        self._send_custom_train(
1318            custom_train_id,
1319            pulse_times,
1320            pulse_voltage_bits,
1321            "send_custom_waveform()",
1322        )
1323
1324    def trigger(self, channels):
1325        """Trigger output channels in software.
1326
1327        The channels start their pulse trains together, in the same timer
1328        cycle. A channel that is already playing a pulse train ignores
1329        the trigger, and `PulsePalDevice.stop` cancels a trigger that has
1330        not started its channel yet.
1331
1332        ```python
1333        P.trigger(3)          # one channel
1334        P.trigger([1, 4])     # several: a list, tuple or NumPy array
1335        ```
1336
1337        Args:
1338            channels: Output channel number, 1-4, or several as a list,
1339                tuple or NumPy array.
1340
1341        Raises:
1342            PulsePalError: If a channel number is not 1-4, or no channel
1343                is given. Nothing is triggered.
1344        """
1345        self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8")
1346
1347    def stop(self, channels=None):
1348        """Stop pulse trains currently playing on the device.
1349
1350        The stopped channels return to their
1351        `PulsePalDevice.resting_voltage`. A soft trigger that has not
1352        started its channel yet is cancelled too.
1353
1354        ```python
1355        P.stop()           # all output channels
1356        P.stop([1, 3, 4])  # channels 1, 3 and 4
1357        ```
1358
1359        Args:
1360            channels: Output channel number, 1-4, or several as a list,
1361                tuple or NumPy array. `None`, the default, stops all
1362                channels. Stopping some channels requires firmware v22
1363                or newer.
1364
1365        Raises:
1366            PulsePalError: If a channel number is not 1-4, or channels are
1367                given and the firmware is older than v22.
1368        """
1369        if self.info.firmware_version < 22:
1370            if channels is not None:
1371                raise PulsePalError("stop() cannot address individual channels before firmware v22")
1372            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")  # Stops all channels
1373            return
1374        bits = 0x0F if channels is None else _common.channel_bits(channels)
1375        self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8")
1376
1377    def status(self):
1378        """Read which channels are playing, and the counters since the last call.
1379
1380        The counters (see `DeviceStatus`) count from the previous call, or
1381        from the device's startup, and each call clears them. Calling it
1382        before and after a trial checks that the TTLs the trial sent
1383        arrived, and started the trains they should:
1384
1385        ```python
1386        P.status()                     # Clears the counters
1387        run_trial()                    # Your code: one TTL on trigger channel 1, linked to channels 1 and 2
1388        s = P.status()
1389        if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]:
1390            print("The trial's trains did not all start")
1391        ```
1392
1393        Requires firmware v22 or newer.
1394
1395        Returns:
1396            A `DeviceStatus`.
1397
1398        Raises:
1399            PulsePalError: If the firmware is older than v22, or the device
1400                does not reply.
1401        """
1402        self._require_firmware(22, "status()")
1403        self._write_serial((self._OP_MENU_BYTE, 101), "uint8")
1404        values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES))
1405        return DeviceStatus(
1406            playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))],
1407            trigger_edges=[None, *values[1:3]],
1408            channel_starts=[None, *values[3:7]],
1409            comm_failures=values[7],
1410        )
1411
1412    def save_program_to_microsd(self, file_name):
1413        """Save the parameters to the device's microSD card, as a program.
1414
1415        The program can be loaded later with
1416        `PulsePalDevice.load_program_from_microsd` or with the joystick
1417        menu's Load Program item. A file of the same name is replaced. To
1418        keep the parameters on this computer instead, see
1419        `PulsePalDevice.export_params`.
1420
1421        ```python
1422        P.save_program_to_microsd("MyProtocol.pps")
1423        ```
1424
1425        Args:
1426            file_name: At most 15 ASCII characters, ending in `.pps`.
1427
1428        Raises:
1429            PulsePalError: If the file name is invalid, or the device could
1430                not write the file to its microSD card. Pulse Pal 2 also
1431                refuses while a channel is playing: its card shares a bus with
1432                the outputs, so stop playback first.
1433        """
1434        self._settings_file_op(file_name, 1, "save_program_to_microsd()")
1435
1436    def load_program_from_microsd(self, file_name):
1437        """Load a program from the device's microSD card.
1438
1439        The device plays the program, and the parameters are read back
1440        into this object (see `PulsePalDevice.sync_from_device`). The
1441        program's trigger modes take effect too, and it is loaded at once,
1442        also in param sync mode.
1443
1444        Args:
1445            file_name: The program's file name, ending in `.pps`, as saved
1446                by `PulsePalDevice.save_program_to_microsd` or the joystick
1447                menu.
1448
1449        Raises:
1450            PulsePalError: If the file name is invalid, or the device could
1451                not load the program: there is no such file on its microSD
1452                card, or it is not a valid Pulse Pal program; or, on Pulse
1453                Pal 2, a channel is playing (see `save_program_to_microsd`).
1454                The device then keeps its parameters, and so does this object.
1455        """
1456        self._settings_file_op(file_name, 2, "load_program_from_microsd()")
1457
1458    def delete_program_from_microsd(self, file_name):
1459        """Delete a program from the device's microSD card.
1460
1461        Args:
1462            file_name: The program's file name, ending in `.pps`.
1463
1464        Raises:
1465            PulsePalError: If the file name is invalid, or the device could
1466                not delete the file, e.g. because there is no such file, or,
1467                on Pulse Pal 2, because a channel is playing.
1468        """
1469        self._settings_file_op(file_name, 3, "delete_program_from_microsd()")
1470
1471    def _settings_file_op(self, file_name, op_byte, context):
1472        """Save (op_byte 1), load (2) or delete (3) a settings file, with op 90."""
1473        valid = (
1474            isinstance(file_name, str)
1475            and all(32 <= ord(character) <= 126 for character in file_name)  # Printable ASCII
1476            and file_name.lower().endswith(".pps")
1477            and 4 < len(file_name) <= 15
1478        )
1479        if not valid:
1480            raise PulsePalError(
1481                f"{context}: the file name must be 1 to 11 ASCII characters followed by .pps, "
1482                f"e.g. 'Protocol1.pps'. Received {file_name!r}."
1483            )
1484        filename_bytes = file_name.encode("ascii")
1485        self._write_serial(
1486            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
1487            "uint8",
1488            list(filename_bytes),
1489            "uint8",
1490        )
1491        if self.info.firmware_version > 21:
1492            # Sent after the file operation has finished. A refused load changes nothing on the device.
1493            refusal = {
1494                1: f"Pulse Pal could not save {file_name!r} to its microSD card. Is the card in place?",
1495                2: f"Pulse Pal could not load {file_name!r}: there is no such file on its microSD card, or it is not "
1496                   "a valid Pulse Pal program. The device keeps its parameters.",
1497                3: f"Pulse Pal could not delete {file_name!r}: there is no such file on its microSD card.",
1498            }[op_byte]
1499            # Pulse Pal 2 replies 2 while a channel plays: its microSD card shares the SPI bus with the DAC
1500            busy = (f"Pulse Pal 2 does not use its microSD card while a channel is playing, because the card shares a "
1501                    f"bus with the outputs, so {file_name!r} was not {('saved', 'loaded', 'deleted')[op_byte - 1]}. "
1502                    "Stop playback first, with stop().")
1503            if op_byte == 2:
1504                busy += " The device keeps its parameters."
1505            self._read_ack(context, refusal_message=refusal, refusal_messages={2: busy})
1506        else:
1507            self._reply_pending = None  # Firmware v21 does not acknowledge op 90 (see _write_raw())
1508            if op_byte == 2:
1509                time.sleep(0.1)  # So allow time for the load
1510        if op_byte == 2:
1511            self.sync_from_device()
1512
1513    def format_microsd(self, *, confirm=True, timeout=30):
1514        """Format the device's microSD card.
1515
1516        Erases every program saved on the device and resets its
1517        parameters to the defaults. By default, asks at the console for
1518        confirmation first; a script that runs on its own, or an AI agent,
1519        passes `confirm=False`.
1520
1521        Requires Pulse Pal hardware v3 or newer.
1522
1523        Args:
1524            confirm: If `True`, ask at the console before anything is
1525                erased. If `False`, format at once.
1526            timeout: Seconds to wait for the device to report that
1527                formatting has finished.
1528
1529        Returns:
1530            `True` once the card has been formatted, or `False` if the
1531            user declines the confirmation prompt.
1532
1533        Raises:
1534            PulsePalError: If the connected hardware is older than v3, if
1535                the device reports that formatting failed, or if it does
1536                not report a result within `timeout` seconds.
1537        """
1538        if self.info.hardware_version < 3:
1539            raise PulsePalError(
1540                "format_microsd() requires hardware v3 or newer."
1541            )
1542
1543        if to_bool(confirm, "confirm"):
1544            print("*** Pulse Pal microSD Formatter ***")
1545            print("This will format Pulse Pal's microSD card,")
1546            print("erase all programs saved on the device")
1547            print("and reset all parameters to defaults.")
1548            reply = input("Do you want to continue (y/n)")
1549            if reply.strip().lower() != "y":
1550                print("Choice confirmed - microSD Card NOT formatted.")
1551                return False
1552
1553        self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8")
1554
1555        # The device replies with lines of status text, the last of which
1556        # contains "!", and then a confirm byte (1 if the card was formatted,
1557        # 0 if not). The confirm byte is sent after the device has reloaded
1558        # its default parameters, so it can arrive well after the text. It
1559        # must be read here: left in the buffer, it would be taken as the
1560        # reply to the next command, and every reply after that would be
1561        # read one byte late.
1562        start = time.time()
1563        message = bytearray()
1564        flag_index = -1
1565        line_end = -1
1566
1567        while time.time() - start < timeout:
1568            n_waiting = self.port.in_waiting
1569            if n_waiting:
1570                message.extend(self.port.read(n_waiting))
1571                flag_index = message.find(b"!")
1572                if flag_index >= 0:
1573                    line_end = message.find(b"\n", flag_index)
1574                if line_end >= 0 and len(message) > line_end + 1:
1575                    break
1576            time.sleep(0.01)
1577        if line_end < 0 or len(message) <= line_end + 1:
1578            raise PulsePalError(
1579                "Pulse Pal did not report the result of formatting "
1580                f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}"
1581            )
1582        self._reply_pending = None  # The whole reply is read (see _write_raw())
1583        confirm_byte = message[line_end + 1]
1584
1585        text = bytes(message[:flag_index]).decode(
1586            "ascii", errors="replace"
1587        ).rstrip()
1588        if text:
1589            print(text)
1590
1591        # The device has loaded its own defaults. They are this class's, but the device keeps no record of
1592        # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the
1593        # MATLAB class does, and this object describes the device again.
1594        self.set_default_params()
1595        if confirm_byte != 1:
1596            raise PulsePalError(
1597                "Pulse Pal could not format its microSD card."
1598            )
1599        return True
1600
1601    def gui(self, block=None, theme=None):
1602        """Open the Pulse Pal parameter GUI, or focus an open one.
1603
1604        The GUI edits its own copy of the parameters, and loads them to
1605        the device when its 'Load to Device' button is clicked. The
1606        window closes automatically when the device is closed or
1607        deleted.
1608
1609        Calling this while the GUI is already open focuses the existing
1610        window rather than opening a second one.
1611
1612        Args:
1613            block: If `True`, the call returns when the GUI is closed. If
1614                `False`, the call returns immediately, and the host
1615                application must run the Tk event loop. If `None`, the
1616                GUI blocks only when the host does not already provide a
1617                Tk event loop, e.g. when launched from a script.
1618            theme: `"light"` or `"dark"` to select the color theme, or
1619                `None` to match the desktop theme. Passing a theme to an
1620                already-open GUI recolors it in place.
1621
1622        Returns:
1623            The `pulsepal.gui.PulsePalGUI` instance driving the window.
1624
1625        Raises:
1626            ValueError: If the theme name is not recognized.
1627        """
1628        gui = getattr(self, "_gui", None)
1629        if gui is not None and not gui.is_closed:
1630            if theme is not None:
1631                gui.set_theme(theme)
1632            gui.focus()
1633            return gui
1634
1635        from .gui import PulsePalGUI
1636
1637        gui = PulsePalGUI(self, theme=theme)
1638        self._gui = gui
1639        gui.start(block=block)
1640        return gui
1641
1642    # ------------------------------------------------------------------
1643    # Connection
1644    # ------------------------------------------------------------------
1645
1646    def close(self):
1647        """Close the connection to the device, and the GUI if open.
1648
1649        The device stops all output channels when the client disconnects,
1650        and shows its own name on its screen again. Safe to call more
1651        than once; later calls do nothing. Called automatically when
1652        leaving a `with` block and when the object is garbage collected,
1653        so pulse trains also stop when the last reference to the object
1654        goes, e.g. when a function that created it returns.
1655        """
1656        self._close(send_disconnect=True)
1657
1658    def _close(self, send_disconnect):
1659        """Close the connection. send_disconnect: tell the device first (op 81). False when the device is in
1660        an unknown state, such as after a failed handshake."""
1661        gui = getattr(self, "_gui", None)
1662        self._gui = None
1663        if gui is not None:
1664            try:
1665                gui.close()
1666            except Exception:
1667                # Cleanup must not raise; Tk may already be torn down
1668                pass
1669
1670        if getattr(self, "_closed", True):
1671            return
1672        self._closed = True
1673        if send_disconnect and self._unsent:
1674            _common.warn(f"PulsePalDevice closed with changes made with auto_sync off that were never sent to the "
1675                         f"device: {self._unsent_names()}.")
1676        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1677        try:
1678            if send_disconnect and self.port and self.port.is_open:
1679                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1680        except Exception:
1681            pass  # The port may already be gone, e.g. the cable was unplugged
1682        finally:
1683            if self.port and self.port.is_open:
1684                self.port.close()
1685
1686    def __enter__(self):
1687        """Enter a `with` block, returning the connected device."""
1688        return self
1689
1690    def __exit__(self, exc_type, exc_value, traceback):
1691        """Disconnect and close the port when leaving a `with` block.
1692
1693        Returns:
1694            `False`, so any exception raised in the block propagates.
1695        """
1696        self.close()
1697        return False
1698
1699    def __del__(self):
1700        """Disconnect and close the port when the object is collected."""
1701        try:
1702            self.close()
1703        except Exception:
1704            # Destructors should not raise; the serial object may already be
1705            # gone during interpreter shutdown.
1706            pass
1707
1708    def __setattr__(self, name, value):
1709        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1710        _common.refuse_unknown_attribute())."""
1711        if name[0] != "_":
1712            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1713        super().__setattr__(name, value)
1714
1715    def __repr__(self):
1716        """Describe the device and its parameters, e.g. with `print(P)`."""
1717        port = getattr(getattr(self, "port", None), "port", None)
1718        lines = [
1719            f"PulsePalDevice on {port} (Pulse Pal {self.info.hardware_version}, "
1720            f"firmware v{self.info.firmware_version})",
1721            f"auto_sync: {self._auto_sync}",
1722        ]
1723        for name in self.info.output_parameter_names:
1724            lines.append(f"{name}: {list(getattr(self, name))}")
1725        lines.append(f"trigger_mode: {list(self._trigger_mode)}")
1726        return "\n".join(lines)
1727
1728    # ------------------------------------------------------------------
1729    # Internals: parameters
1730    # ------------------------------------------------------------------
1731
1732    def _has_param_sync(self):
1733        """Param sync mode is on Pulse Pal 3, with firmware v22 or newer."""
1734        return (self.info.hardware_version or 2) > 2 and (self.info.firmware_version or 0) > 21
1735
1736    def _normalize_output_value(self, code, value, channel):
1737        """Check a value for an output parameter, and return it as the parameter's list holds it:
1738        a time as the device plays it, rounded to its timer cycle.
1739
1740        Raises PulsePalError, before anything is sent, for a value the device cannot play. The
1741        device refuses most of them too, but it also resets them (see validateOutputParams() in
1742        /Firmware/PulsePal/USBOps.ino), so the local copy would describe a different program.
1743        """
1744        name, kind = _OUTPUT_PARAMETERS[code]
1745        label = f"{name} on channel {channel}"
1746        if kind == "bool":
1747            value = to_bool(value, label)
1748            if code == _CONTINUOUS_LOOP_CODE and value and self.info.firmware_version < 22:
1749                raise PulsePalError("continuous_loop requires firmware v22 or newer.")
1750            return value
1751        if kind == "volts":
1752            self._volts_to_bits(value, label)
1753            return float(value)
1754        if kind in ("time", "pulse_time"):
1755            cycles = self._seconds_to_cycles(value, label)
1756            # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001,
1757            # but is exactly 10 cycles of 10 us
1758            if kind == "pulse_time" and cycles < self._min_pulse_cycles():
1759                raise PulsePalError(
1760                    f"{label} must be at least {self._min_pulse_seconds()} s "
1761                    f"({self._min_pulse_cycles_text()} of the device's {self.info.cycle_period_us} us "
1762                    f"timer), so that trigger channels can detect the pulses. Received {value!r}."
1763                )
1764            return self._cycles_to_seconds(cycles)  # The time the device plays
1765        if kind == "train_id":
1766            self._check_whole_number(value, label, 0, self.info.n_custom_pulse_trains)
1767            return int(value)
1768        # "target"
1769        return to_name(value, CUSTOM_TRAIN_TARGETS, label, codes={"Pulses": 0, "Bursts": 1})
1770
1771    def _normalize_trigger_mode(self, value):
1772        modes = self.info.trigger_modes
1773        return to_name(value, modes, "trigger_mode", codes={mode: TRIGGER_MODES.index(mode) for mode in modes})
1774
1775    def _encode_output_param(self, code, values):
1776        """Convert output parameter values to device units.
1777
1778        Returns the converted values and the datatype the device reads for this
1779        parameter code: DAC codes for voltages, hardware timer cycles for times,
1780        and bytes for everything else.
1781        """
1782        name, kind = _OUTPUT_PARAMETERS[code]
1783        if kind == "volts":
1784            return [self._volts_to_bits(v, name) for v in values], "uint16"
1785        if kind in ("time", "pulse_time"):
1786            return [self._seconds_to_cycles(v, name) for v in values], "uint32"
1787        if kind == "target":
1788            return [CUSTOM_TRAIN_TARGETS.index(v) for v in values], "uint8"
1789        return [int(v) for v in values], "uint8"
1790
1791    def _apply_output_param(self, code, values):
1792        """Check new values for all four channels of an output parameter, and with auto_sync on,
1793        program the channels whose value changes. Returns them as the parameter's list holds them."""
1794        values = [self._normalize_output_value(code, v, ch) for ch, v in enumerate(values, start=1)]
1795        name = _OUTPUT_PARAMETERS[code][0]
1796        if not self._auto_sync:
1797            self._unsent = self._unsent | {name}  # Until sync_to_device() sends it (see auto_sync)
1798        else:
1799            if code in (_BURST_DURATION_CODE, _CUSTOM_TRAIN_TARGET_CODE):
1800                # Sent alone, so it must suit the other parameter as the device holds it. In a batch() block, or
1801                # with auto_sync off, both are checked together by sync_to_device().
1802                new = {name: values}
1803                self._check_custom_bursts(new.get("custom_train_target", list(self._custom_train_target)[1:]),
1804                                          new.get("burst_duration", list(self._burst_duration)[1:]))
1805            # Compared as the device receives them: 5 and 5.00001 V are the same DAC code
1806            old_data, _ = self._encode_output_param(code, list(getattr(self, name))[1:])
1807            new_data, _ = self._encode_output_param(code, values)
1808            channels = [ch for ch in range(1, 5) if old_data[ch - 1] != new_data[ch - 1]]
1809            self._send_output_param(code, values, channels, f"setting {name}")
1810        return values
1811
1812    def _send_output_param(self, code, values, channels, context):
1813        """Program one output parameter on some output channels: op 91 if all four change, op 74 for each otherwise.
1814
1815        The other channels are left as the device holds them. Sending all four (op 91) for a change to one put this
1816        object's copy of the others back on the device, undoing what the joystick menu, or a program loaded with it,
1817        had set there.
1818        """
1819        if self.info.firmware_version > 21 and len(channels) == 4:
1820            data, datatype = self._encode_output_param(code, values)
1821            self._write_serial((self._OP_MENU_BYTE, 91, code), "uint8", data, datatype)
1822            self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, channels))
1823            return
1824        for channel in channels:
1825            # A refusal reads back every channel this assignment changes, so that the copy describes the device for
1826            # those already sent too
1827            self._send_output_param_to_channel(code, channel, values[channel - 1], context, refresh_channels=channels)
1828
1829    def _send_output_param_to_channel(self, code, channel, value, context, refresh_channels=None):
1830        """Program one output parameter on one channel (op 74)."""
1831        if code == _CONTINUOUS_LOOP_CODE and self.info.firmware_version < 22:
1832            return  # Firmware v21 has no parameter 18, and _normalize_output_value() lets only False through
1833        data, datatype = self._encode_output_param(code, [value])
1834        self._write_serial((self._OP_MENU_BYTE, 74, code, channel), "uint8", data, datatype)
1835        self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, refresh_channels or (channel,)))
1836
1837    def _apply_trigger_mode(self, values):
1838        """Check new trigger modes, and with auto_sync on, program the trigger channels whose mode changes."""
1839        modes = [self._normalize_trigger_mode(v) for v in values]
1840        if not self._auto_sync:
1841            self._unsent = self._unsent | {"trigger_mode"}
1842        else:
1843            channels = [ch for ch in (1, 2) if modes[ch - 1] != self._trigger_mode[ch]]
1844            if len(channels) == 2:
1845                self._send_trigger_modes(modes, "setting trigger_mode")
1846            elif channels:  # One: op 74, which leaves the other trigger channel as the device holds it
1847                channel = channels[0]
1848                self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel,
1849                                    TRIGGER_MODES.index(modes[channel - 1])), "uint8")
1850                self._read_ack("setting trigger_mode", on_refusal=self._refresh_trigger_mode)
1851        return modes
1852
1853    def _send_trigger_modes(self, modes, context):
1854        """Program both trigger channels' modes: op 91, or op 74 per channel on firmware v21."""
1855        codes = [TRIGGER_MODES.index(mode) for mode in modes]
1856        if self.info.firmware_version > 21:
1857            self._write_serial((self._OP_MENU_BYTE, 91, _TRIGGER_MODE_CODE, *codes), "uint8")
1858            self._read_ack(context, on_refusal=self._refresh_trigger_mode)
1859        else:
1860            for channel, mode_code in enumerate(codes, start=1):
1861                self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, mode_code), "uint8")
1862                self._read_ack(context)
1863
1864    def _snapshot(self):
1865        """All parameters, and which are unsent, for batch() to restore if its block fails."""
1866        names = [*self.info.output_parameter_names, "trigger_mode"]
1867        return {name: list(getattr(self, name))[1:] for name in names}, self._unsent
1868
1869    def _restore(self, snapshot):
1870        params, self._unsent = snapshot
1871        for name, values in params.items():
1872            getattr(self, name)._store(values)
1873
1874    def _unsent_names(self):
1875        """The settings assigned with auto_sync off and not yet sent, for messages, in parameter code order."""
1876        return ", ".join(name for name in _SETTING_NAMES if name in self._unsent)
1877
1878    def _check_sync(self):
1879        """Check the whole local copy before sync_to_device() sends it."""
1880        for code, (name, _) in _OUTPUT_PARAMETERS.items():
1881            values = getattr(self, name)
1882            for ch in range(1, 5):
1883                self._normalize_output_value(code, values[ch], ch)
1884        for ch in (1, 2):
1885            self._normalize_trigger_mode(self._trigger_mode[ch])
1886        self._check_custom_bursts(list(self._custom_train_target)[1:], list(self._burst_duration)[1:])
1887
1888    @staticmethod
1889    def _check_custom_bursts(targets, burst_durations):
1890        """Refuse a custom train targeting bursts on a channel with no burst duration.
1891
1892        Its times are burst onsets, and a burst of no duration plays one timer cycle of a pulse at each: a
1893        program that cannot be what was meant. (The device once played one pulse and then left the channel
1894        "playing", ignoring triggers until it was stopped.)
1895        """
1896        for channel, (target, burst) in enumerate(zip(targets, burst_durations), start=1):
1897            if target == "Bursts" and burst == 0:
1898                raise PulsePalError(
1899                    f"Output channel {channel}: custom_train_target 'Bursts' makes the custom train's times burst "
1900                    "onsets, so burst_duration must be above 0. Set burst_duration first, or set both in a batch() "
1901                    "block."
1902                )
1903
1904    def _send_sync(self):
1905        """Send every parameter in one command: op 92 on firmware v22 or newer, op 73 on v21."""
1906        self._unsent = frozenset()  # All are sent below
1907        if self.info.firmware_version > 21:
1908            self._sync_all_params()
1909        else:
1910            self._sync_all_params_legacy()
1911        self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)
1912
1913    def _device_bytes(self, name):
1914        """The byte values of a byte parameter for channels 1-4, as the device reads them."""
1915        values = list(getattr(self, name))[1:]
1916        if name == "custom_train_target":
1917            return [CUSTOM_TRAIN_TARGETS.index(v) for v in values]
1918        return [int(v) for v in values]
1919
1920    def _sync_all_params(self):
1921        """Send all parameters using the packed sync op (firmware v22+).
1922
1923        Values are grouped by width so that the whole program travels as
1924        one uint32 block, one uint16 block and one uint8 block. See op 92
1925        in /Firmware/PROTOCOL.md.
1926        """
1927        time_values = []
1928        for name in _TIME_PARAMETERS:
1929            time_values.extend(self._seconds_to_cycles(v) for v in list(getattr(self, name))[1:])
1930        voltage_values = []
1931        for name in _VOLTAGE_PARAMETERS:
1932            voltage_values.extend(self._volts_to_bits(v) for v in list(getattr(self, name))[1:])
1933        single_byte_values = []
1934        for name in ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop",
1935                     "continuous_loop", "link_trigger_channel1", "link_trigger_channel2"):
1936            single_byte_values.extend(self._device_bytes(name))
1937        single_byte_values.extend(TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:])
1938
1939        self._write_serial(
1940            (self._OP_MENU_BYTE, 92),
1941            "uint8",
1942            time_values,
1943            "uint32",
1944            voltage_values,
1945            "uint16",
1946            single_byte_values,
1947            "uint8",
1948        )
1949
1950    def _sync_all_params_legacy(self):
1951        """Send all parameters using the legacy sync op (firmware v21).
1952
1953        Equivalent to `_sync_all_params`, but lays the program out
1954        channel by channel as the older firmware expects. Continuous
1955        loop mode is not part of it.
1956        """
1957        program_values_32 = []
1958        program_values_16 = []
1959        program_values_8 = []
1960        for ch in range(1, 5):
1961            program_values_32.extend(self._seconds_to_cycles(getattr(self, name)[ch]) for name in _TIME_PARAMETERS)
1962        for ch in range(1, 5):
1963            program_values_16.extend(self._volts_to_bits(getattr(self, name)[ch]) for name in _VOLTAGE_PARAMETERS)
1964        byte_values = {name: self._device_bytes(name) for name in
1965                       ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop")}
1966        for ch in range(4):
1967            program_values_8.extend(byte_values[name][ch] for name in byte_values)
1968        links = self._device_bytes("link_trigger_channel1") + self._device_bytes("link_trigger_channel2")
1969        modes = [TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]]
1970
1971        self._write_serial(
1972            (self._OP_MENU_BYTE, 73),
1973            "uint8",
1974            program_values_32,
1975            "uint32",
1976            program_values_16,
1977            "uint16",
1978            program_values_8 + links + modes,
1979            "uint8",
1980        )
1981
1982    def _read_device_params(self):
1983        """Read the device's parameters (op 93).
1984
1985        Returns a dict of parameter values for channels 1-4 (trigger
1986        channels 1-2 for trigger_mode), as the parameter lists hold them.
1987        Continuous loop mode is not part of op 93, so `continuous_loop` is
1988        not included.
1989        """
1990        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
1991        # The device sends the whole parameter set as one message, so read it in one go and
1992        # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md.
1993        message = self._read_raw(self._PARAM_MESSAGE_BYTES)
1994        values = struct.unpack(f"{self._ENDIANNESS}32I12H26B", message)
1995        params = {}
1996        for index, name in enumerate(_TIME_PARAMETERS):
1997            params[name] = [self._cycles_to_seconds(x) for x in values[index * 4:index * 4 + 4]]
1998        for index, name in enumerate(_VOLTAGE_PARAMETERS):
1999            params[name] = [self._bits_to_volts(x) for x in values[32 + index * 4:32 + index * 4 + 4]]
2000        for index, name in enumerate(("is_biphasic", "custom_train_id", "custom_train_target",
2001                                      "custom_train_loop", "link_trigger_channel1", "link_trigger_channel2")):
2002            codes = values[44 + index * 4:44 + index * 4 + 4]
2003            if name == "custom_train_id":
2004                params[name] = list(codes)
2005            elif name == "custom_train_target":
2006                params[name] = [CUSTOM_TRAIN_TARGETS[min(code, 1)] for code in codes]
2007            else:
2008                params[name] = [bool(code) for code in codes]
2009        params["trigger_mode"] = [TRIGGER_MODES[min(code, 3)] for code in values[68:70]]
2010        return params
2011
2012    def _refresh_output_param(self, code, channels):
2013        """Read an output parameter back from the device into the local copy, for these channels.
2014
2015        Called after the device refused a value for them, which it resets. The parameter's other
2016        channels, and other parameters, keep their local values, which may hold edits not yet synced.
2017        """
2018        name = _OUTPUT_PARAMETERS[code][0]
2019        if self.info.firmware_version < 22 or code == _CONTINUOUS_LOOP_CODE:
2020            return  # No op 93 to read it with, or not in op 93
2021        device_values = self._read_device_params()[name]
2022        settings = getattr(self, name)
2023        local_values = list(settings)[1:]
2024        for ch in channels:
2025            local_values[ch - 1] = device_values[ch - 1]
2026        settings._store(local_values)
2027
2028    def _refresh_trigger_mode(self):
2029        """Read the trigger modes back from the device, after the device refused a new one."""
2030        if self.info.firmware_version > 21:
2031            self._trigger_mode._store(self._read_device_params()["trigger_mode"])
2032
2033    def _refresh_after_refused_sync(self):
2034        """Read the parameters back after the device refused a sync_to_device() set.
2035
2036        The device programs the set with the refused values reset, so the local copy is read back.
2037        In param sync mode the device stores the set instead, and op 93 would return the parameters
2038        playing now, so the local copy is left as it is.
2039        """
2040        if self.info.firmware_version > 21 and "Param Sync" not in list(self._trigger_mode)[1:]:
2041            self.sync_from_device()
2042
2043    def _check_whole_number(self, value, name, low, high):
2044        """Raise PulsePalError unless value is a whole number from low to high."""
2045        valid = (
2046            isinstance(value, numbers.Real)
2047            and not isinstance(value, (bool, np.bool_))
2048            and math.isfinite(value)
2049            and value == int(value)
2050            and low <= value <= high
2051        )
2052        if not valid:
2053            raise PulsePalError(
2054                f"{name} must be a whole number from {low} to {high}. Received {value!r}."
2055            )
2056
2057    def _min_pulse_cycles(self):
2058        """The shortest pulse phase, interval or train, in timer cycles: DeviceInfo.min_pulse_width_us. See
2059        _MIN_PULSE_CYCLES."""
2060        return int(round(self.info.min_pulse_width_us / self.info.cycle_period_us))
2061
2062    def _min_pulse_cycles_text(self):
2063        """_min_pulse_cycles() for messages: "1 cycle", "2 cycles"."""
2064        cycles = self._min_pulse_cycles()
2065        return f"{cycles} cycle" if cycles == 1 else f"{cycles} cycles"
2066
2067    def _min_pulse_seconds(self):
2068        """The shortest pulse phase, interval or train, in seconds: DeviceInfo.min_pulse_width_us."""
2069        return self._cycles_to_seconds(self._min_pulse_cycles())
2070
2071    def _require_firmware(self, minimum_version, context):
2072        """Raise unless the device firmware is new enough for an op."""
2073        if (
2074            self.info.firmware_version is None
2075            or self.info.firmware_version < minimum_version
2076        ):
2077            raise PulsePalError(
2078                f"{context} requires firmware v{minimum_version} or newer. "
2079                f"Detected firmware is v{self.info.firmware_version}."
2080            )
2081
2082    # ------------------------------------------------------------------
2083    # Internals: custom trains
2084    # ------------------------------------------------------------------
2085
2086    def _send_custom_train(self, custom_train_id, pulse_times_cycles,
2087                           pulse_voltage_bits, context):
2088        """Send a custom pulse train, already converted to device units (NumPy integer arrays).
2089
2090        Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76
2091        (trains 1 and 2 only) on firmware v21.
2092        """
2093        n_trains = self.info.n_custom_pulse_trains
2094        valid_id = (
2095            isinstance(custom_train_id, numbers.Integral)
2096            and not isinstance(custom_train_id, (bool, np.bool_))
2097            and 1 <= custom_train_id <= n_trains
2098        )
2099        if not valid_id:
2100            raise PulsePalError(
2101                f"{context}: custom_train_id must be an integer from 1 to "
2102                f"{n_trains}. Received {custom_train_id!r}."
2103            )
2104        train_id = int(custom_train_id)
2105        n_pulses = len(pulse_times_cycles)
2106        if n_pulses == 0:
2107            # The device refuses an empty train too: it would hold no pulse times to play
2108            raise PulsePalError(
2109                f"{context}: a custom train needs at least one pulse. To play nothing on a channel, set its "
2110                "custom_train_id to a train that holds what it should play, or do not trigger it."
2111            )
2112        if n_pulses > self.info.max_custom_pulses:
2113            raise PulsePalError(
2114                f"{context}: {n_pulses} pulses were given. Pulse Pal can "
2115                f"store up to {self.info.max_custom_pulses} pulses per "
2116                "custom train."
2117            )
2118        # Times are multiples of _min_pulse_cycles() (see _custom_times_to_cycles()), so increasing times are far
2119        # enough apart for a trigger channel to detect each pulse.
2120        too_close = np.flatnonzero(np.diff(pulse_times_cycles) < self._min_pulse_cycles())
2121        if too_close.size:
2122            i = int(too_close[0]) + 1
2123            raise PulsePalError(
2124                f"{context}: pulse times must increase, by at least "
2125                f"{self._min_pulse_seconds()} s ({self._min_pulse_cycles_text()} "
2126                f"of the device's {self.info.cycle_period_us} us "
2127                f"timer). Pulse {i + 1} is at "
2128                f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, "
2129                f"and pulse {i} is at "
2130                f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s."
2131            )
2132        if pulse_times_cycles[-1] > self._max_time_cycles():
2133            raise PulsePalError(
2134                f"{context}: the last pulse starts at {self._cycles_to_seconds(pulse_times_cycles[-1])} s. "
2135                f"Pulse times must be at most {self.info.max_time} s."
2136            )
2137
2138        if self.info.firmware_version > 21:
2139            header = bytes([self._OP_MENU_BYTE, 95, train_id - 1])
2140        else:
2141            header = bytes([self._OP_MENU_BYTE, 74 + train_id])
2142        self._write_raw(
2143            header + struct.pack("<I", n_pulses)
2144            + np.asarray(pulse_times_cycles).astype("<u4").tobytes()
2145            + np.asarray(pulse_voltage_bits).astype("<u2").tobytes()
2146        )
2147        self._read_ack(context)
2148
2149    def _number_array(self, values, name, description):
2150        """Return values (a scalar, a list, a tuple or a 1-D NumPy array) as a float64 array, refusing booleans,
2151        strings and anything else that is not a number, as the scalar conversions do.
2152
2153        An array of two or more dimensions, or a list of lists, is refused rather than flattened: one value per
2154        pulse is a list, and a 2-D one is a mistake, such as both rows of a stereo waveform. (The MATLAB class, which
2155        took a matrix's longest side as its pulse count and sent all of it, sent the rest to the device as commands.)
2156        """
2157        if isinstance(values, np.ndarray):
2158            self._check_one_dimensional(values, name)
2159            if values.dtype.kind in "iuf":
2160                return values.ravel().astype(float)
2161        items = self._as_list(values)
2162        refused_types = tuple(kind for kind in {type(item) for item in items}  # Each type once: fast for long lists
2163                              if issubclass(kind, (bool, np.bool_, str, bytes)))
2164        if refused_types:
2165            item = next(item for item in items if isinstance(item, refused_types))
2166            raise PulsePalError(f"{name} must be {description}. Received {item!r}.")
2167        try:
2168            array = np.asarray(items, dtype=float)
2169        except (TypeError, ValueError):
2170            for item in items:
2171                try:
2172                    float(item)
2173                except (TypeError, ValueError):
2174                    raise PulsePalError(f"{name} must be {description}. Received {item!r}.") from None
2175            raise
2176        self._check_one_dimensional(array, name)  # A list of lists
2177        return array
2178
2179    @staticmethod
2180    def _check_one_dimensional(array, name):
2181        if array.ndim > 1:
2182            raise PulsePalError(
2183                f"{name} must be one-dimensional, one value per pulse: a list, a tuple or a 1-D NumPy array. "
2184                f"Received an array of shape {array.shape}."
2185            )
2186
2187    @staticmethod
2188    def _first(values, refused):
2189        """The first value at a True position of the boolean array `refused`, for an error message."""
2190        i = int(np.flatnonzero(refused)[0])
2191        item = values.ravel()[i] if isinstance(values, np.ndarray) else PulsePalDevice._as_list(values)[i]
2192        return item.item() if isinstance(item, np.generic) else item
2193
2194    def _custom_times_to_cycles(self, values, name):
2195        """Convert custom pulse times to timer cycles, all at once: _custom_time_cycles() for each of them.
2196
2197        Each must be a time the device can play, and a whole number of DeviceInfo.min_pulse_width_us to the nearest
2198        microsecond, as in the MATLAB class. Returns an int64 array.
2199        """
2200        seconds = self._number_array(values, name, "a time of 0 s or more")
2201        refused = ~(np.isfinite(seconds) & (seconds >= 0))
2202        if refused.any():
2203            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {self._first(values, refused)!r}.")
2204        # The same arithmetic as _seconds_to_cycles() and _custom_time_cycles(): NumPy's round() also takes a
2205        # halfway value to the even integer
2206        refused = np.round(seconds * float(self.info.cycle_frequency)) > self._max_time_cycles()
2207        if refused.any():
2208            raise PulsePalError(
2209                f"{name} must be at most {self.info.max_time} s, the longest time the device's screen "
2210                f"shows. Received {self._first(values, refused)!r}."
2211            )
2212        microseconds = np.round(seconds * 1e6)
2213        step_us = int(self.info.min_pulse_width_us)
2214        refused = np.mod(microseconds, step_us) != 0
2215        if refused.any():
2216            raise PulsePalError(
2217                f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). "
2218                f"Received {self._first(values, refused)!r}."
2219            )
2220        return (microseconds // int(self.info.cycle_period_us)).astype(np.int64)
2221
2222    def _volts_array_to_bits(self, values, name):
2223        """Convert voltages to DAC codes, all at once: _volts_to_bits() for each of them. Returns an int64 array."""
2224        volts = self._number_array(values, name, "in [-10, 10] V")
2225        refused = ~((volts >= -10) & (volts <= 10))  # Also refuses NaN
2226        if refused.any():
2227            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {self._first(values, refused)!r}.")
2228        return np.round((volts + 10) / 20 * self._DAC_BITMAX).astype(np.int64)
2229
2230    # ------------------------------------------------------------------
2231    # Internals: serial port and units
2232    # ------------------------------------------------------------------
2233
2234    def _write_serial(self, *args):
2235        """Write one or more data/type pairs to the serial port, in one write."""
2236        if len(args) % 2 != 0:
2237            raise PulsePalError(
2238                "Serial writes require data/type argument pairs."
2239            )
2240
2241        payload = bytearray()
2242        for i in range(0, len(args), 2):
2243            payload.extend(self._pack_values(args[i], args[i + 1]))
2244        self._write_raw(bytes(payload))
2245
2246    def _write_raw(self, payload):
2247        """Write a command to the serial port, in one write.
2248
2249        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
2250        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it put every reply
2251        after it out of step: status() reported channels playing that were not, and assignments the device took
2252        raised refusals. So it is discarded first (_discard_lost_reply()). Then this command's reply, if it has one,
2253        is pending until it has been read: _read_raw() marks it read, and a method that reads a reply otherwise
2254        (format_microsd()) sets _reply_pending back to None itself.
2255        """
2256        if self._reply_pending is not None:
2257            self._discard_lost_reply()
2258        if payload[1] not in _OPS_WITHOUT_TRACKED_REPLY:
2259            self._reply_pending = payload[1]  # Before the write: a Ctrl-C can come as soon as it returns
2260        bytes_written = self.port.write(payload)
2261        if bytes_written != len(payload):
2262            raise PulsePalError(
2263                f"Wrote {bytes_written} byte(s), expected to write "
2264                f"{len(payload)} byte(s)."
2265            )
2266
2267    def _discard_lost_reply(self):
2268        """Discard what arrives of a reply that was not read in full (see _write_raw()), and bring this object back
2269        in step with the device.
2270
2271        The command may have changed the parameters, unknown to this object: an assignment interrupted after its
2272        write, for one, which the device took and this object did not. So after one of _PARAMETER_OPS, they are read
2273        back (op 93), on firmware v22 or newer and outside param sync mode, where op 93 returns the parameters playing
2274        rather than a set stored for the next sync edge.
2275        """
2276        op = self._reply_pending
2277        self._reply_pending = None
2278        n_late = len(_common.read_until_quiet(self.port))
2279        read_back = (op in _PARAMETER_OPS and self.info.firmware_version > 21
2280                     and "Param Sync" not in list(self._trigger_mode)[1:])
2281        _common.warn_lost_reply(f"op {op}", n_late,
2282                                ", and the parameters were read back from the device" if read_back else "")
2283        if read_back:
2284            try:
2285                self.sync_from_device()
2286            except PulsePalError as exc:
2287                raise PulsePalError(
2288                    f"Pulse Pal did not send its parameters, read back after the reply to an earlier command was "
2289                    f"lost. {_common.NO_REPLY_HINT}"
2290                ) from exc
2291
2292    def _read_raw(self, n_bytes):
2293        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
2294        longer pending (see _write_raw()). Only connecting reads a reply in more than one call."""
2295        message_bytes = self.port.read(n_bytes)
2296        if len(message_bytes) < n_bytes:
2297            raise PulsePalError(
2298                f"Pulse Pal sent {len(message_bytes)} of the {n_bytes} byte(s) of its reply within "
2299                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
2300            )
2301        self._reply_pending = None
2302        return message_bytes
2303
2304    def _read_serial(self, n_values, datatype):
2305        """Read values from the serial port and unpack them with struct."""
2306        fmt = self._STRUCT_FORMATS[datatype]
2307        n_values = int(n_values)
2308        n_bytes = n_values * struct.calcsize(fmt)
2309        message_bytes = self._read_raw(n_bytes)
2310
2311        values = struct.unpack(
2312            f"{self._ENDIANNESS}{n_values}{fmt}",
2313            message_bytes,
2314        )
2315        if n_values == 1:
2316            return values[0]
2317        return list(values)
2318
2319    def _read_ack(self, context, on_refusal=None, refusal_message=None, refusal_messages=None):
2320        """Read a one-byte acknowledgement from the device.
2321
2322        The device replies 1 if it executed the command, or 0 if it rejected
2323        the command because a value was out of range. On a 0, on_refusal()
2324        is called before the error is raised: the device resets a value it
2325        refuses, so callers use it to read the value back into the local copy.
2326        refusal_message replaces the error's usual message, for a command
2327        refused for another reason. refusal_messages maps other replies to
2328        their own message, e.g. op 90's 2.
2329        """
2330        try:
2331            acknowledgement = self._read_serial(1, "uint8")
2332        except PulsePalError as exc:
2333            raise PulsePalError(
2334                f"Pulse Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
2335                f"{_common.NO_REPLY_HINT}"
2336            ) from exc
2337        if acknowledgement != 1:
2338            refusal = PulsePalError(
2339                (refusal_messages or {}).get(acknowledgement) or
2340                refusal_message or
2341                f"Pulse Pal rejected the command sent by {context}. "
2342                "This usually means that a channel number, parameter code or "
2343                "value was out of range for the connected device."
2344            )
2345            if on_refusal is not None:
2346                try:
2347                    on_refusal()
2348                except PulsePalError as exc:
2349                    raise refusal from exc
2350            raise refusal
2351
2352    def _pack_values(self, values, datatype):
2353        """Pack scalar, list/tuple, or NumPy array values into bytes."""
2354        fmt = self._STRUCT_FORMATS[datatype]
2355        values_list = self._as_list(values)
2356        min_value, max_value = self._TYPE_RANGES[datatype]
2357        normalized = []
2358        for value in values_list:
2359            value = int(value)
2360            if not min_value <= value <= max_value:
2361                raise PulsePalError(
2362                    f"Value {value} is out of range for {datatype} "
2363                    f"({min_value} to {max_value})."
2364                )
2365            normalized.append(value)
2366        return struct.pack(f"{self._ENDIANNESS}{len(normalized)}{fmt}", *normalized)
2367
2368    @staticmethod
2369    def _as_list(values):
2370        """Return values as a flat list, wrapping scalars in one."""
2371        if isinstance(values, np.ndarray):
2372            return values.ravel().tolist()
2373        if isinstance(values, (bytes, bytearray, str)):
2374            return [values]
2375        if isinstance(values, numbers.Number):
2376            return [values]
2377        try:
2378            return list(values)
2379        except TypeError:
2380            return [values]
2381
2382    def _volts_to_bits(self, value, name="voltage"):
2383        """Convert -10 V to +10 V to the corresponding DAC code.
2384
2385        Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do.
2386        Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would
2387        play a different voltage.
2388        """
2389        try:
2390            volts = float(value)
2391        except (TypeError, ValueError):
2392            volts = float("nan")
2393        if isinstance(value, (bool, np.bool_, str)) or not -10 <= volts <= 10:  # Also refuses NaN
2394            raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.")
2395        return int(round((volts + 10) / 20 * self._DAC_BITMAX))
2396
2397    def _bits_to_volts(self, value):
2398        """Convert a DAC code to volts, snapping to a clean value within 1 LSB."""
2399        raw_volts = (float(value) / self._DAC_BITMAX * 20) - 10
2400        lsb_volts = 20.0 / self._DAC_BITMAX
2401        # The nearest 3 decimal number (e.g. 5.000, 4.255), if the code is within 1 LSB of it
2402        clean_volts = round(raw_volts, 3)
2403        if abs(raw_volts - clean_volts) <= lsb_volts:
2404            return clean_volts
2405        return round(raw_volts, 4)
2406
2407    def _seconds_to_cycles(self, value, name="time"):
2408        """Convert seconds to the corresponding refresh-cycle count.
2409
2410        Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do:
2411        125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is
2412        negative, not a number, or longer than DeviceInfo.max_time.
2413        """
2414        try:
2415            seconds = float(value)
2416        except (TypeError, ValueError):
2417            seconds = float("nan")
2418        if isinstance(value, (bool, np.bool_, str)) or not (math.isfinite(seconds) and seconds >= 0):
2419            raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.")
2420        cycles = int(round(seconds * float(self.info.cycle_frequency)))
2421        if cycles > self._max_time_cycles():
2422            raise PulsePalError(
2423                f"{name} must be at most {self.info.max_time} s, the longest time the device's screen "
2424                f"shows. Received {value!r}."
2425            )
2426        return cycles
2427
2428    def _max_time_cycles(self):
2429        """DeviceInfo.max_time in timer cycles: 999999990 on a 10 us timer, 199999998 on a 50 us timer."""
2430        return int(round(self.info.max_time * float(self.info.cycle_frequency)))
2431
2432    def _custom_time_cycles(self, value, name):
2433        """Convert a custom pulse time or sampling period to timer cycles.
2434
2435        It must be a whole number of DeviceInfo.min_pulse_width_us, to the nearest microsecond,
2436        as in the MATLAB class. A time between two steps is refused, not rounded
2437        as the time parameters are, so that a train plays as it was written.
2438        """
2439        self._seconds_to_cycles(value, name)  # Checks that it is a time the device can play
2440        microseconds = int(round(float(value) * 1e6))
2441        step_us = int(self.info.min_pulse_width_us)
2442        if microseconds % step_us:
2443            raise PulsePalError(
2444                f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). Received {value!r}."
2445            )
2446        return microseconds // int(self.info.cycle_period_us)
2447
2448    def _cycles_to_seconds(self, value):
2449        """Convert hardware timer cycle counts to seconds."""
2450        return float(value) / float(self.info.cycle_frequency)

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 (see PulsePalDevice.set_default_params).

from pulsepal import PulsePalDevice

P = PulsePalDevice("COM3")
P.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 run serialportlist() again. 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 lists indexed by channel number, with index 0 unused (see "Parameters" above). print(P) shows them all, and PulsePalDevice.export_params returns them as a dict.

PulsePalDevice(port_name=None, *, serial_number=None, timeout=10)
672    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
673        """Open a connection to a Pulse Pal device.
674
675        Opens the serial port, exchanges the handshake, verifies the
676        firmware version, reads the device properties into
677        `PulsePalDevice.info`, and programs the device with the default
678        parameters.
679
680        ```python
681        P = PulsePalDevice("COM3")
682        P = PulsePalDevice(serial_number="20345420")  # Wherever it is plugged in
683        ```
684
685        Args:
686            port_name: USB serial port for the Pulse Pal device, such as
687                `COM3` on Windows or `/dev/ttyACM0` on Linux.
688            serial_number: Instead of `port_name`, the device's USB serial
689                number (`DeviceInfo.serial_number`), which finds its port.
690                Pulse Pal 3 only: Pulse Pal 2 has none.
691            timeout: Serial read timeout, in seconds.
692
693        Firmware newer than this module knows (`v22`) is used with a
694        warning: new firmware only adds commands, so everything here still
695        works with it. Update the package to use what is new.
696
697        Raises:
698            PulsePalError: If the device does not return the expected
699                handshake (for example, it runs Wave Pal or Synth Pal
700                firmware), or its firmware is older than v21, or no port
701                has the serial number given. The port is closed again
702                before any error is raised.
703            serial.SerialException: If the serial port cannot be opened,
704                e.g. because another program has it open (on Linux and
705                macOS, another session of these classes, which lock it).
706        """
707        self._closed = True
708        self._gui = None
709        self._auto_sync = True
710        self.info = DeviceInfo()
711        self._create_settings()
712        port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice")
713        self.port = serial.Serial(
714            port_name,
715            self._BAUD_RATE,
716            timeout=timeout,
717            rtscts=True,
718            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
719            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
720            # it, such as another of these classes, gets an error instead
721            exclusive=True,
722        )
723        self._closed = False
724        try:
725            self._start_session(port_name)
726        except BaseException:
727            # Otherwise the port stays open until the object is garbage
728            # collected, and a second attempt, or WavePalDevice, cannot open
729            # it. The device is in an unknown state, so it is not sent the
730            # disconnect op.
731            self._close(send_disconnect=False)
732            raise

Open a connection to a Pulse Pal device.

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

P = PulsePalDevice("COM3")
P = PulsePalDevice(serial_number="20345420")  # Wherever it is plugged in
Arguments:
  • port_name: USB serial port for the Pulse Pal device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • serial_number: Instead of port_name, the device's USB serial number (DeviceInfo.serial_number), which finds its port. Pulse Pal 3 only: Pulse Pal 2 has none.
  • timeout: Serial read timeout, in seconds.

Firmware newer than this module knows (v22) is used with a warning: new firmware only adds commands, so everything here still works with it. Update the package to use what is new.

Raises:
  • PulsePalError: If the device does not return the expected handshake (for example, it runs Wave Pal or Synth Pal firmware), or its firmware is older than v21, or no port has the serial number given. The port is closed again before any error is raised.
  • serial.SerialException: If the serial port cannot be opened, e.g. because another program has it open (on Linux and macOS, another session of these classes, which lock it).
port: serial.serialposix.Serial

The open serial.Serial port connected to the device.

info: DeviceInfo

Properties of the connected device. See DeviceInfo.

is_biphasic
390    def get(self):
391        return getattr(self, "_" + name)
phase1_voltage
390    def get(self):
391        return getattr(self, "_" + name)
phase2_voltage
390    def get(self):
391        return getattr(self, "_" + name)
resting_voltage
390    def get(self):
391        return getattr(self, "_" + name)
phase1_duration
390    def get(self):
391        return getattr(self, "_" + name)
inter_phase_interval
390    def get(self):
391        return getattr(self, "_" + name)
phase2_duration
390    def get(self):
391        return getattr(self, "_" + name)
inter_pulse_interval
390    def get(self):
391        return getattr(self, "_" + name)
burst_duration
390    def get(self):
391        return getattr(self, "_" + name)
inter_burst_interval
390    def get(self):
391        return getattr(self, "_" + name)
pulse_train_duration
390    def get(self):
391        return getattr(self, "_" + name)
pulse_train_delay
390    def get(self):
391        return getattr(self, "_" + name)
custom_train_id
390    def get(self):
391        return getattr(self, "_" + name)
custom_train_target
390    def get(self):
391        return getattr(self, "_" + name)
custom_train_loop
390    def get(self):
391        return getattr(self, "_" + name)
continuous_loop
390    def get(self):
391        return getattr(self, "_" + name)
trigger_mode
390    def get(self):
391        return getattr(self, "_" + name)
@staticmethod
def serialportlist(ports_to_list='available'):
830    @staticmethod
831    def serialportlist(ports_to_list="available"):
832        """Return the names of the USB serial ports on this computer.
833
834        Called on the class, without connecting to a device, to find the
835        port name to pass to `PulsePalDevice`:
836
837        ```python
838        from pulsepal import PulsePalDevice
839
840        ports = PulsePalDevice.serialportlist()
841        P = PulsePalDevice(ports[0])
842        ```
843
844        Args:
845            ports_to_list: `available` to list only the ports that are
846                not already in use, or `all` to list every USB serial
847                port. Not case sensitive.
848
849        Returns:
850            Sorted list of port names, such as `["COM3", "COM7"]` on
851            Windows or `["/dev/ttyACM0"]` on Linux.
852
853        Raises:
854            PulsePalError: If `ports_to_list` is not `available` or
855                `all`.
856        """
857        return _common.serialportlist(ports_to_list)

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.
auto_sync
863    @property
864    def auto_sync(self):
865        """Whether assigning a parameter programs the device at once.
866
867        `True` (the default): each assignment, such as
868        `P.phase1_voltage[1] = 5`, programs the device at once, also while
869        a trigger channel is in param sync mode. It sends the channels
870        whose value it changes, and nothing for a channel that already
871        holds the value assigned: the other channels keep what the device
872        holds, e.g. values set with its joystick (read them with
873        `PulsePalDevice.sync_from_device`).
874
875        `False`: assignments change only this object's copy of the
876        parameters, checking each value, and
877        `PulsePalDevice.sync_to_device` sends all of them in one command.
878        Turning it back on does not send changes made meanwhile, so it
879        raises `pulsepal.PulsePalError` while there are any: call
880        `PulsePalDevice.sync_to_device` first to send them, or
881        `PulsePalDevice.sync_from_device` to discard them. A
882        `PulsePalDevice.batch` block does it all for you. `close()` warns of
883        changes never sent.
884        """
885        return self._auto_sync

Whether assigning a parameter programs the device at once.

True (the default): each assignment, such as P.phase1_voltage[1] = 5, programs the device at once, also while a trigger channel is in param sync mode. It sends the channels whose value it changes, and nothing for a channel that already holds the value assigned: the other channels keep what the device holds, e.g. values set with its joystick (read them with PulsePalDevice.sync_from_device).

False: assignments change only this object's copy of the parameters, checking each value, and PulsePalDevice.sync_to_device sends all of them in one command. Turning it back on does not send changes made meanwhile, so it raises pulsepal.PulsePalError while there are any: call PulsePalDevice.sync_to_device first to send them, or PulsePalDevice.sync_from_device to discard them. A PulsePalDevice.batch block does it all for you. close() warns of changes never sent.

def batch(self):
901    def batch(self):
902        """Change several parameters, and send them in one command.
903
904        Returns a context manager. In its block, assignments change only
905        this object's copy of the parameters (as with
906        `PulsePalDevice.auto_sync` off), and when the block ends,
907        `PulsePalDevice.sync_to_device` sends all of them at once. If the
908        block raises an error, nothing is sent, and the parameters go
909        back to what they were.
910
911        ```python
912        with P.batch():
913            P.phase1_voltage = [5, 5, 2.5, 2.5]
914            P.phase1_duration[3] = 0.002
915        ```
916
917        In param sync mode, the device stores the parameters for the next
918        rising edge on the param sync channel (see
919        `PulsePalDevice.trigger_mode`).
920        """
921        return _common.batch(self)

Change several parameters, and send them in one command.

Returns a context manager. In its block, assignments change only this object's copy of the parameters (as with PulsePalDevice.auto_sync off), and when the block ends, PulsePalDevice.sync_to_device sends all of them at once. If the block raises an error, nothing is sent, and the parameters go back to what they were.

with P.batch():
    P.phase1_voltage = [5, 5, 2.5, 2.5]
    P.phase1_duration[3] = 0.002

In param sync mode, the device stores the parameters for the next rising edge on the param sync channel (see PulsePalDevice.trigger_mode).

def sync_to_device(self):
923    def sync_to_device(self):
924        """Program the device with this object's copy of all parameters.
925
926        Sends every output and trigger parameter to the device in a single
927        command. Use it after assignments made with
928        `PulsePalDevice.auto_sync` off; with it on, they have already
929        reached the device.
930
931        ```python
932        P.auto_sync = False
933        P.phase1_voltage[1:5] = [5] * 4
934        P.sync_to_device()
935        ```
936
937        On Pulse Pal 3, if either trigger channel is in param sync mode
938        (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores
939        the parameters instead of programming them, and loads them on the
940        next rising edge of that channel. The device still checks every
941        value. This is the only method whose effect is deferred that way.
942
943        Raises:
944            PulsePalError: If a value is out of range for its parameter
945                (nothing is sent), or the device does not acknowledge the
946                command.
947        """
948        self._check_sync()
949        self._send_sync()

Program the device with this object's copy of all parameters.

Sends every output and trigger parameter to the device in a single command. Use it after assignments made with PulsePalDevice.auto_sync off; with it on, they have already reached the device.

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

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

Raises:
  • PulsePalError: If a value is out of range for its parameter (nothing is sent), or the device does not acknowledge the command.
def sync_from_device(self):
951    def sync_from_device(self):
952        """Read all parameters from the device into this object's copy.
953
954        Overwrites the parameter lists with the program currently on the
955        device, e.g. after it has been reprogrammed from its thumb
956        joystick. `PulsePalDevice.continuous_loop` is left as it is: the
957        device does not report it. Changes made with
958        `PulsePalDevice.auto_sync` off and not yet sent are discarded, but
959        for `PulsePalDevice.continuous_loop`.
960
961        Requires firmware v22 or newer.
962
963        Raises:
964            PulsePalError: If the connected firmware is older than v22,
965                or the device does not return the full parameter set.
966        """
967        self._require_firmware(22, "sync_from_device()")
968        for name, values in self._read_device_params().items():
969            getattr(self, name)._store(values)
970        self._unsent = self._unsent & {"continuous_loop"}  # Not read: a change to it is still unsent

Read all parameters from the device into this object's copy.

Overwrites the parameter lists with the program currently on the device, e.g. after it has been reprogrammed from its thumb joystick. PulsePalDevice.continuous_loop is left as it is: the device does not report it. Changes made with PulsePalDevice.auto_sync off and not yet sent are discarded, but for PulsePalDevice.continuous_loop.

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 set_default_params(self):
972    def set_default_params(self):
973        """Program the device with the default parameters.
974
975        The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart
976        (about 909 a second), for 1 second, resting at 0 V, on all four
977        output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms
978        after 0.1 ms at rest. No output channel is linked to a trigger
979        channel, so a TTL starts nothing until a script links it, and both
980        trigger channels are in normal mode. They are the device's own
981        defaults, which it also loads at startup, after a comm failure
982        and after formatting its microSD card. They are sent at once,
983        also while `PulsePalDevice.auto_sync` is off. The constructor
984        calls it.
985        """
986        for name, value in _DEFAULT_OUTPUT_VALUES.items():
987            getattr(self, name)._store([value] * 4)
988        self._trigger_mode._store(["Normal"] * 2)
989        if self._has_param_sync():
990            # A device left in param sync mode would store the sync below instead of running it,
991            # leaving the device on its old program until a TTL arrived. A trigger mode sent on
992            # its own is not deferred that way, so both trigger channels leave param sync mode
993            # first. See PulsePalDevice.trigger_mode.
994            self._send_trigger_modes(["Normal"] * 2, "set_default_params()")
995        self._send_sync()

Program the device with the default parameters.

The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart (about 909 a second), for 1 second, resting at 0 V, on all four output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms after 0.1 ms at rest. No output channel is linked to a trigger channel, so a TTL starts nothing until a script links it, and both trigger channels are in normal mode. They are the device's own defaults, which it also loads at startup, after a comm failure and after formatting its microSD card. They are sent at once, also while PulsePalDevice.auto_sync is off. The constructor calls it.

def export_params(self):
 997    def export_params(self):
 998        """Return every parameter, as a dict of plain lists.
 999
1000        Keyed by parameter name (`DeviceInfo.output_parameter_names`, then
1001        `"trigger_mode"`), with one value per channel and no unused index
1002        0. It holds only numbers, booleans and names, so it can be saved
1003        with `json` and logged with your data, to record exactly what the
1004        device played. `PulsePalDevice.import_params` programs it again.
1005
1006        ```python
1007        import json
1008        with open("trial_params.json", "w") as f:
1009            json.dump(P.export_params(), f)
1010        ```
1011        """
1012        names = [*self.info.output_parameter_names, "trigger_mode"]
1013        return {name: list(getattr(self, name))[1:] for name in names}

Return every parameter, as a dict of plain lists.

Keyed by parameter name (DeviceInfo.output_parameter_names, then "trigger_mode"), with one value per channel and no unused index

  1. It holds only numbers, booleans and names, so it can be saved with json and logged with your data, to record exactly what the device played. PulsePalDevice.import_params programs it again.
import json
with open("trial_params.json", "w") as f:
    json.dump(P.export_params(), f)
def import_params(self, params):
1015    def import_params(self, params):
1016        """Program the device with parameters exported by `PulsePalDevice.export_params`.
1017
1018        All of them are checked first, and then sent in one command, as
1019        at the end of a `PulsePalDevice.batch` block: in param sync mode,
1020        the device stores them for the next sync edge. Parameters missing
1021        from `params` keep their values. A name that is not a parameter
1022        is skipped with a warning, so that parameters exported by a newer
1023        version of this package, which may have more, can still be
1024        imported; if none is, an error is raised.
1025
1026        A whole program file, as the GUIs save it, is imported too: its
1027        parameters, not its custom trains (a warning says so, if it has
1028        any).
1029
1030        ```python
1031        with open("MyProgram.json") as f:
1032            P.import_params(json.load(f))
1033        ```
1034
1035        Args:
1036            params: A dict of parameter name to one value per channel (a
1037                list, tuple or NumPy array), as `export_params` returns,
1038                or a program file's contents. The MATLAB class's names
1039                (`phase1Voltage`) are accepted too, so that the parameters
1040                its `exportParams()` returns, saved with `jsonencode`, can
1041                be imported.
1042
1043        Raises:
1044            PulsePalError: If a value is invalid, or no name in `params` is
1045                a parameter. Nothing is sent, and the parameters keep the
1046                values they had.
1047        """
1048        params = _params_to_import(params)
1049        with self.batch():
1050            for name, values in params.items():
1051                setattr(self, name, values)

Program the device with parameters exported by PulsePalDevice.export_params.

All of them are checked first, and then sent in one command, as at the end of a PulsePalDevice.batch block: in param sync mode, the device stores them for the next sync edge. Parameters missing from params keep their values. A name that is not a parameter is skipped with a warning, so that parameters exported by a newer version of this package, which may have more, can still be imported; if none is, an error is raised.

A whole program file, as the GUIs save it, is imported too: its parameters, not its custom trains (a warning says so, if it has any).

with open("MyProgram.json") as f:
    P.import_params(json.load(f))
Arguments:
  • params: A dict of parameter name to one value per channel (a list, tuple or NumPy array), as export_params returns, or a program file's contents. The MATLAB class's names (phase1Voltage) are accepted too, so that the parameters its exportParams() returns, saved with jsonencode, can be imported.
Raises:
  • PulsePalError: If a value is invalid, or no name in params is a parameter. Nothing is sent, and the parameters keep the values they had.
def set_fixed_voltage(self, channels, voltage):
1053    def set_fixed_voltage(self, channels, voltage):
1054        """Set output channels to a fixed voltage.
1055
1056        Each channel holds the voltage until it is set again or until a
1057        pulse train is triggered on it.
1058
1059        ```python
1060        P.set_fixed_voltage(4, 2.5)
1061        P.set_fixed_voltage([1, 3], -1)
1062        ```
1063
1064        Args:
1065            channels: Output channel number, 1-4, or several as a list,
1066                tuple or NumPy array.
1067            voltage: Voltage to set on each of them, in volts [-10, 10].
1068
1069        Raises:
1070            PulsePalError: If a channel number is not 1-4, the voltage is
1071                outside [-10, 10] (nothing is sent), or the device does not
1072                acknowledge the command.
1073        """
1074        channels = _common.channel_numbers(channels)
1075        voltage_bits = self._volts_to_bits(voltage, "voltage")
1076        for channel in channels:
1077            self._write_serial(
1078                (self._OP_MENU_BYTE, 79, channel),
1079                "uint8",
1080                voltage_bits,
1081                "uint16",
1082            )
1083            self._read_ack("set_fixed_voltage()")

Set output channels to a fixed voltage.

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

P.set_fixed_voltage(4, 2.5)
P.set_fixed_voltage([1, 3], -1)
Arguments:
  • channels: Output channel number, 1-4, or several as a list, tuple or NumPy array.
  • voltage: Voltage to set on each of them, in volts [-10, 10].
Raises:
  • PulsePalError: If a channel number is not 1-4, the voltage is outside [-10, 10] (nothing is sent), or the device does not acknowledge the command.
def set_calibration(self, channel, voltage_offset):
1085    def set_calibration(self, channel, voltage_offset):
1086        """Calibrate the zero code of an output channel.
1087
1088        The offset is added to every voltage the channel produces, to
1089        correct for DAC offset error. It is stored in the device's
1090        EEPROM and reloaded on boot, so it only needs to be set once.
1091
1092        Requires Pulse Pal hardware v3 or newer.
1093
1094        Args:
1095            channel: Output channel number, 1-4.
1096            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
1097
1098        Raises:
1099            PulsePalError: If the connected hardware is older than v3,
1100                `channel` is not 1-4, `voltage_offset` is outside
1101                [-0.1, 0.1], or the device does not acknowledge the
1102                command.
1103        """
1104        if self.info.hardware_version < 3:
1105            raise PulsePalError(
1106                "set_calibration() requires hardware v3 or newer."
1107            )
1108        if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)):
1109            raise PulsePalError("channel must be 1, 2, 3 or 4")
1110        if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1):  # Also refuses NaN
1111            raise PulsePalError(
1112                "voltage_offset for zero code calibration must be in range "
1113                "[-0.1, 0.1]"
1114            )
1115        # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it
1116        voltage_bits = int(round(voltage_offset * (1 / (20 / 65536))))
1117        self._write_serial(
1118            (self._OP_MENU_BYTE, 96, channel - 1),
1119            "uint8",
1120            voltage_bits,
1121            "int16",
1122        )
1123        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, channel is not 1-4, voltage_offset is outside [-0.1, 0.1], or the device does not acknowledge the command.
def set_screen_saver(self, enabled, timeout=1800):
1125    def set_screen_saver(self, enabled, timeout=1800):
1126        """Switch the device's screen saver on or off, and set its timeout.
1127
1128        With the screen saver on, the device dims its screen once it has
1129        been left alone for `timeout` seconds: no command from the
1130        computer, no rising edge on a trigger channel, and no joystick
1131        click or push. The next of these brings the screen back. Both
1132        settings are stored in the device's EEPROM and kept through power
1133        cycles; the screen saver can also be switched on and off from the
1134        device's joystick menu. A new device has it on, with 1800 s.
1135
1136        The device saves the settings once no channel is playing. Saving
1137        pauses its timer, usually for about 20 us but, once in about 2000
1138        changes, for tens of milliseconds, which would delay a trigger.
1139        Change the settings before an experiment rather than during one.
1140
1141        Requires firmware v22 or newer. Only Pulse Pal 3 has a screen
1142        saver: Pulse Pal 2 accepts `enabled=False` only.
1143
1144        ```python
1145        P.set_screen_saver(True, 300)  # dim after 5 minutes
1146        P.set_screen_saver(False)
1147        ```
1148
1149        Args:
1150            enabled: True (or 1) to switch the screen saver on, False (or
1151                0) to switch it off.
1152            timeout: Seconds without activity before the screen dims, a
1153                whole number from 1 to 65535. It is sent with every call,
1154                so leaving it out sets 1800 s.
1155
1156        Raises:
1157            PulsePalError: If the firmware is older than v22, the
1158                screen saver is switched on with hardware older than v3,
1159                a value is out of range, or the device does not
1160                acknowledge the command.
1161        """
1162        self._require_firmware(22, "set_screen_saver()")
1163        enabled = to_bool(enabled, "enabled")
1164        if not (
1165            isinstance(timeout, numbers.Real)
1166            and not isinstance(timeout, bool)
1167            and math.isfinite(timeout)
1168            and timeout == int(timeout)
1169            and 1 <= timeout <= 65535
1170        ):
1171            raise PulsePalError(
1172                "timeout must be a whole number of seconds from 1 to 65535"
1173            )
1174        if enabled and (self.info.hardware_version or 2) < 3:
1175            raise PulsePalError(
1176                "The screen saver requires hardware v3 or newer: Pulse Pal 2 "
1177                "accepts set_screen_saver(False) only."
1178            )
1179        self._write_serial(
1180            (self._OP_MENU_BYTE, 99, int(enabled)),
1181            "uint8",
1182            int(timeout),
1183            "uint16",
1184        )
1185        self._read_ack("set_screen_saver()")

Switch the device's screen saver on or off, and set its timeout.

With the screen saver on, the device dims its screen once it has been left alone for timeout seconds: no command from the computer, no rising edge on a trigger channel, and no joystick click or push. The next of these brings the screen back. Both settings are stored in the device's EEPROM and kept through power cycles; the screen saver can also be switched on and off from the device's joystick menu. A new device has it on, with 1800 s.

The device saves the settings once no channel is playing. Saving pauses its timer, usually for about 20 us but, once in about 2000 changes, for tens of milliseconds, which would delay a trigger. Change the settings before an experiment rather than during one.

Requires firmware v22 or newer. Only Pulse Pal 3 has a screen saver: Pulse Pal 2 accepts enabled=False only.

P.set_screen_saver(True, 300)  # dim after 5 minutes
P.set_screen_saver(False)
Arguments:
  • enabled: True (or 1) to switch the screen saver on, False (or 0) to switch it off.
  • timeout: Seconds without activity before the screen dims, a whole number from 1 to 65535. It is sent with every call, so leaving it out sets 1800 s.
Raises:
  • PulsePalError: If the firmware is older than v22, the screen saver is switched on with hardware older than v3, a value is out of range, or the device does not acknowledge the command.
def send_custom_pulse_train(self, custom_train_id, pulse_times, voltages):
1191    def send_custom_pulse_train(
1192        self,
1193        custom_train_id,
1194        pulse_times,
1195        voltages,
1196    ):
1197        """Load a custom pulse train onto the device.
1198
1199        A custom pulse train is an arbitrary list of pulse onset times
1200        and voltages, replacing the parametric pulse voltage and onset
1201        timing. Set `PulsePalDevice.custom_train_id` on an output channel
1202        to play the train there. Each pulse takes the channel's own phase
1203        durations; a biphasic pulse's second phase is its voltage with the
1204        sign reversed. A biphasic pulse always plays whole, and a pulse
1205        whose time comes while it plays is dropped, so that every pulse
1206        played is on time.
1207
1208        ```python
1209        P.send_custom_pulse_train(
1210            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
1211        )
1212        P.custom_train_id[1] = 2
1213        ```
1214
1215        Loading a train stops the output channels playing it, and their
1216        next trigger plays the new train. On Pulse Pal 3, while a trigger
1217        channel is in param sync mode (see `PulsePalDevice.trigger_mode`),
1218        the device stores the train instead, as it stores the parameters
1219        `PulsePalDevice.sync_to_device` sends: channels go on playing the
1220        old train, and the next rising edge on the param sync channel
1221        makes the new one current. A channel playing the old train at that
1222        edge finishes it, and plays the new one from its next trigger. So
1223        the next trial's custom trains can be loaded during the current
1224        trial. Leaving param sync mode makes a stored train current at
1225        once.
1226
1227        Args:
1228            custom_train_id: Custom train to load, from 1 to
1229                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1230                4 on Pulse Pal 3).
1231            pulse_times: Pulse onset times, in seconds, relative to the
1232                start of the train, increasing. Each is a multiple of
1233                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1234                100 us on Pulse Pal 2), from 0 to
1235                `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array.
1236            voltages: Voltage of each pulse, in volts [-10, 10]. Must be
1237                the same length as `pulse_times`.
1238
1239        Raises:
1240            PulsePalError: If `custom_train_id` is out of range,
1241                `pulse_times` or `voltages` is not one-dimensional (a 2-D
1242                array is refused, not flattened), they differ in length,
1243                there are no pulses, or more than
1244                `DeviceInfo.max_custom_pulses`, a pulse time is not a
1245                multiple of `DeviceInfo.min_pulse_width_us`, is out of
1246                range or is not later than the one before it, or the
1247                device does not acknowledge the command.
1248        """
1249        pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times")
1250        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1251        if len(pulse_times_cycles) != len(pulse_voltage_bits):
1252            raise PulsePalError(
1253                "pulse_times and voltages must be the same length."
1254            )
1255        self._send_custom_train(
1256            custom_train_id,
1257            pulse_times_cycles,
1258            pulse_voltage_bits,
1259            "send_custom_pulse_train()",
1260        )

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. Each pulse takes the channel's own phase durations; a biphasic pulse's second phase is its voltage with the sign reversed. A biphasic pulse always plays whole, and a pulse whose time comes while it plays is dropped, so that every pulse played is on time.

P.send_custom_pulse_train(
    2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
)
P.custom_train_id[1] = 2

Loading a train stops the output channels playing it, and their next trigger plays the new train. On Pulse Pal 3, while a trigger channel is in param sync mode (see PulsePalDevice.trigger_mode), the device stores the train instead, as it stores the parameters PulsePalDevice.sync_to_device sends: channels go on playing the old train, and the next rising edge on the param sync channel makes the new one current. A channel playing the old train at that edge finishes it, and plays the new one from its next trigger. So the next trial's custom trains can be loaded during the current trial. Leaving param sync mode makes a stored train current at once.

Arguments:
  • custom_train_id: Custom train to load, from 1 to DeviceInfo.n_custom_pulse_trains (2 on Pulse Pal 2, 4 on Pulse Pal 3).
  • pulse_times: Pulse onset times, in seconds, relative to the start of the train, increasing. Each is a multiple of DeviceInfo.min_pulse_width_us (10 us on Pulse Pal 3, 100 us on Pulse Pal 2), from 0 to MAX_TIME. Accepts a list, tuple or 1-D NumPy array.
  • voltages: Voltage of each pulse, in volts [-10, 10]. Must be the same length as pulse_times.
Raises:
  • PulsePalError: If custom_train_id is out of range, pulse_times or voltages is not one-dimensional (a 2-D array is refused, not flattened), they differ in length, there are no pulses, or more than DeviceInfo.max_custom_pulses, a pulse time is not a multiple of DeviceInfo.min_pulse_width_us, is out of range or is not later than the one before it, or the device does not acknowledge the command.
def send_custom_waveform(self, custom_train_id, sampling_period, voltages):
1262    def send_custom_waveform(
1263        self,
1264        custom_train_id,
1265        sampling_period,
1266        voltages,
1267    ):
1268        """Load an arbitrary waveform onto the device.
1269
1270        A convenience shorthand for
1271        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
1272        adjoining pulses, so that `voltages` is played as a waveform
1273        sampled every `sampling_period` seconds. It is loaded as a custom
1274        train is: see `PulsePalDevice.send_custom_pulse_train` for what
1275        happens to channels playing it, and in param sync mode.
1276
1277        Set the channel's `PulsePalDevice.phase1_duration` to
1278        `sampling_period` as well, so that each sample is held for the
1279        sampling period.
1280
1281        ```python
1282        import math
1283
1284        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
1285        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
1286        P.custom_train_id[2] = 1
1287        P.phase1_duration[2] = 0.001
1288        ```
1289
1290        Args:
1291            custom_train_id: Custom train to load, from 1 to
1292                `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2,
1293                4 on Pulse Pal 3).
1294            sampling_period: In seconds: a multiple of
1295                `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3,
1296                100 us on Pulse Pal 2). Each voltage is
1297                held for this long.
1298            voltages: Waveform samples, in volts [-10, 10].
1299                Accepts a list, tuple or 1-D NumPy array.
1300
1301        Raises:
1302            PulsePalError: If `custom_train_id` is out of range, there
1303                are no samples, or more than
1304                `DeviceInfo.max_custom_pulses`, `sampling_period` is not a
1305                positive multiple of `DeviceInfo.min_pulse_width_us`, the
1306                last sample would start after `MAX_TIME`, or the device
1307                does not acknowledge the command.
1308        """
1309        pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages")
1310        period_cycles = self._custom_time_cycles(sampling_period, "sampling_period")
1311        if period_cycles == 0:
1312            raise PulsePalError(
1313                f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. "
1314                f"Received {sampling_period!r}."
1315            )
1316        pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64)
1317        self._send_custom_train(
1318            custom_train_id,
1319            pulse_times,
1320            pulse_voltage_bits,
1321            "send_custom_waveform()",
1322        )

Load an arbitrary waveform onto the device.

A convenience shorthand for PulsePalDevice.send_custom_pulse_train with evenly spaced, adjoining pulses, so that voltages is played as a waveform sampled every sampling_period seconds. It is loaded as a custom train is: see PulsePalDevice.send_custom_pulse_train for what happens to channels playing it, and in param sync mode.

Set the channel's PulsePalDevice.phase1_duration to sampling_period 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.custom_train_id[2] = 1
P.phase1_duration[2] = 0.001
Arguments:
  • custom_train_id: Custom train to load, from 1 to DeviceInfo.n_custom_pulse_trains (2 on Pulse Pal 2, 4 on Pulse Pal 3).
  • sampling_period: In seconds: a multiple of DeviceInfo.min_pulse_width_us (10 us on Pulse Pal 3, 100 us on Pulse Pal 2). Each voltage is held for this long.
  • voltages: Waveform samples, in volts [-10, 10]. Accepts a list, tuple or 1-D NumPy array.
Raises:
def trigger(self, channels):
1324    def trigger(self, channels):
1325        """Trigger output channels in software.
1326
1327        The channels start their pulse trains together, in the same timer
1328        cycle. A channel that is already playing a pulse train ignores
1329        the trigger, and `PulsePalDevice.stop` cancels a trigger that has
1330        not started its channel yet.
1331
1332        ```python
1333        P.trigger(3)          # one channel
1334        P.trigger([1, 4])     # several: a list, tuple or NumPy array
1335        ```
1336
1337        Args:
1338            channels: Output channel number, 1-4, or several as a list,
1339                tuple or NumPy array.
1340
1341        Raises:
1342            PulsePalError: If a channel number is not 1-4, or no channel
1343                is given. Nothing is triggered.
1344        """
1345        self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8")

Trigger output channels in software.

The channels start their pulse trains together, in the same timer cycle. A channel that is already playing a pulse train ignores the trigger, and PulsePalDevice.stop cancels a trigger that has not started its channel yet.

P.trigger(3)          # one channel
P.trigger([1, 4])     # several: a list, tuple or NumPy array
Arguments:
  • channels: Output channel number, 1-4, or several as a list, tuple or NumPy array.
Raises:
  • PulsePalError: If a channel number is not 1-4, or no channel is given. Nothing is triggered.
def stop(self, channels=None):
1347    def stop(self, channels=None):
1348        """Stop pulse trains currently playing on the device.
1349
1350        The stopped channels return to their
1351        `PulsePalDevice.resting_voltage`. A soft trigger that has not
1352        started its channel yet is cancelled too.
1353
1354        ```python
1355        P.stop()           # all output channels
1356        P.stop([1, 3, 4])  # channels 1, 3 and 4
1357        ```
1358
1359        Args:
1360            channels: Output channel number, 1-4, or several as a list,
1361                tuple or NumPy array. `None`, the default, stops all
1362                channels. Stopping some channels requires firmware v22
1363                or newer.
1364
1365        Raises:
1366            PulsePalError: If a channel number is not 1-4, or channels are
1367                given and the firmware is older than v22.
1368        """
1369        if self.info.firmware_version < 22:
1370            if channels is not None:
1371                raise PulsePalError("stop() cannot address individual channels before firmware v22")
1372            self._write_serial((self._OP_MENU_BYTE, 80), "uint8")  # Stops all channels
1373            return
1374        bits = 0x0F if channels is None else _common.channel_bits(channels)
1375        self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8")

Stop pulse trains currently playing on the device.

The stopped channels return to their PulsePalDevice.resting_voltage. A soft trigger that has not started its channel yet is cancelled too.

P.stop()           # all output channels
P.stop([1, 3, 4])  # channels 1, 3 and 4
Arguments:
  • channels: Output channel number, 1-4, or several as a list, tuple or NumPy array. None, the default, stops all channels. Stopping some channels requires firmware v22 or newer.
Raises:
  • PulsePalError: If a channel number is not 1-4, or channels are given and the firmware is older than v22.
def status(self):
1377    def status(self):
1378        """Read which channels are playing, and the counters since the last call.
1379
1380        The counters (see `DeviceStatus`) count from the previous call, or
1381        from the device's startup, and each call clears them. Calling it
1382        before and after a trial checks that the TTLs the trial sent
1383        arrived, and started the trains they should:
1384
1385        ```python
1386        P.status()                     # Clears the counters
1387        run_trial()                    # Your code: one TTL on trigger channel 1, linked to channels 1 and 2
1388        s = P.status()
1389        if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]:
1390            print("The trial's trains did not all start")
1391        ```
1392
1393        Requires firmware v22 or newer.
1394
1395        Returns:
1396            A `DeviceStatus`.
1397
1398        Raises:
1399            PulsePalError: If the firmware is older than v22, or the device
1400                does not reply.
1401        """
1402        self._require_firmware(22, "status()")
1403        self._write_serial((self._OP_MENU_BYTE, 101), "uint8")
1404        values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES))
1405        return DeviceStatus(
1406            playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))],
1407            trigger_edges=[None, *values[1:3]],
1408            channel_starts=[None, *values[3:7]],
1409            comm_failures=values[7],
1410        )

Read which channels are playing, and the counters since the last call.

The counters (see DeviceStatus) count from the previous call, or from the device's startup, and each call clears them. Calling it before and after a trial checks that the TTLs the trial sent arrived, and started the trains they should:

P.status()                     # Clears the counters
run_trial()                    # Your code: one TTL on trigger channel 1, linked to channels 1 and 2
s = P.status()
if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]:
    print("The trial's trains did not all start")

Requires firmware v22 or newer.

Returns:

A DeviceStatus.

Raises:
  • PulsePalError: If the firmware is older than v22, or the device does not reply.
def save_program_to_microsd(self, file_name):
1412    def save_program_to_microsd(self, file_name):
1413        """Save the parameters to the device's microSD card, as a program.
1414
1415        The program can be loaded later with
1416        `PulsePalDevice.load_program_from_microsd` or with the joystick
1417        menu's Load Program item. A file of the same name is replaced. To
1418        keep the parameters on this computer instead, see
1419        `PulsePalDevice.export_params`.
1420
1421        ```python
1422        P.save_program_to_microsd("MyProtocol.pps")
1423        ```
1424
1425        Args:
1426            file_name: At most 15 ASCII characters, ending in `.pps`.
1427
1428        Raises:
1429            PulsePalError: If the file name is invalid, or the device could
1430                not write the file to its microSD card. Pulse Pal 2 also
1431                refuses while a channel is playing: its card shares a bus with
1432                the outputs, so stop playback first.
1433        """
1434        self._settings_file_op(file_name, 1, "save_program_to_microsd()")

Save the parameters to the device's microSD card, as a program.

The program can be loaded later with PulsePalDevice.load_program_from_microsd or with the joystick menu's Load Program item. A file of the same name is replaced. To keep the parameters on this computer instead, see PulsePalDevice.export_params.

P.save_program_to_microsd("MyProtocol.pps")
Arguments:
  • file_name: At most 15 ASCII characters, ending in .pps.
Raises:
  • PulsePalError: If the file name is invalid, or the device could not write the file to its microSD card. Pulse Pal 2 also refuses while a channel is playing: its card shares a bus with the outputs, so stop playback first.
def load_program_from_microsd(self, file_name):
1436    def load_program_from_microsd(self, file_name):
1437        """Load a program from the device's microSD card.
1438
1439        The device plays the program, and the parameters are read back
1440        into this object (see `PulsePalDevice.sync_from_device`). The
1441        program's trigger modes take effect too, and it is loaded at once,
1442        also in param sync mode.
1443
1444        Args:
1445            file_name: The program's file name, ending in `.pps`, as saved
1446                by `PulsePalDevice.save_program_to_microsd` or the joystick
1447                menu.
1448
1449        Raises:
1450            PulsePalError: If the file name is invalid, or the device could
1451                not load the program: there is no such file on its microSD
1452                card, or it is not a valid Pulse Pal program; or, on Pulse
1453                Pal 2, a channel is playing (see `save_program_to_microsd`).
1454                The device then keeps its parameters, and so does this object.
1455        """
1456        self._settings_file_op(file_name, 2, "load_program_from_microsd()")

Load a program from the device's microSD card.

The device plays the program, and the parameters are read back into this object (see PulsePalDevice.sync_from_device). The program's trigger modes take effect too, and it is loaded at once, also in param sync mode.

Arguments:
Raises:
  • PulsePalError: If the file name is invalid, or the device could not load the program: there is no such file on its microSD card, or it is not a valid Pulse Pal program; or, on Pulse Pal 2, a channel is playing (see save_program_to_microsd). The device then keeps its parameters, and so does this object.
def delete_program_from_microsd(self, file_name):
1458    def delete_program_from_microsd(self, file_name):
1459        """Delete a program from the device's microSD card.
1460
1461        Args:
1462            file_name: The program's file name, ending in `.pps`.
1463
1464        Raises:
1465            PulsePalError: If the file name is invalid, or the device could
1466                not delete the file, e.g. because there is no such file, or,
1467                on Pulse Pal 2, because a channel is playing.
1468        """
1469        self._settings_file_op(file_name, 3, "delete_program_from_microsd()")

Delete a program from the device's microSD card.

Arguments:
  • file_name: The program's file name, ending in .pps.
Raises:
  • PulsePalError: If the file name is invalid, or the device could not delete the file, e.g. because there is no such file, or, on Pulse Pal 2, because a channel is playing.
def format_microsd(self, *, confirm=True, timeout=30):
1513    def format_microsd(self, *, confirm=True, timeout=30):
1514        """Format the device's microSD card.
1515
1516        Erases every program saved on the device and resets its
1517        parameters to the defaults. By default, asks at the console for
1518        confirmation first; a script that runs on its own, or an AI agent,
1519        passes `confirm=False`.
1520
1521        Requires Pulse Pal hardware v3 or newer.
1522
1523        Args:
1524            confirm: If `True`, ask at the console before anything is
1525                erased. If `False`, format at once.
1526            timeout: Seconds to wait for the device to report that
1527                formatting has finished.
1528
1529        Returns:
1530            `True` once the card has been formatted, or `False` if the
1531            user declines the confirmation prompt.
1532
1533        Raises:
1534            PulsePalError: If the connected hardware is older than v3, if
1535                the device reports that formatting failed, or if it does
1536                not report a result within `timeout` seconds.
1537        """
1538        if self.info.hardware_version < 3:
1539            raise PulsePalError(
1540                "format_microsd() requires hardware v3 or newer."
1541            )
1542
1543        if to_bool(confirm, "confirm"):
1544            print("*** Pulse Pal microSD Formatter ***")
1545            print("This will format Pulse Pal's microSD card,")
1546            print("erase all programs saved on the device")
1547            print("and reset all parameters to defaults.")
1548            reply = input("Do you want to continue (y/n)")
1549            if reply.strip().lower() != "y":
1550                print("Choice confirmed - microSD Card NOT formatted.")
1551                return False
1552
1553        self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8")
1554
1555        # The device replies with lines of status text, the last of which
1556        # contains "!", and then a confirm byte (1 if the card was formatted,
1557        # 0 if not). The confirm byte is sent after the device has reloaded
1558        # its default parameters, so it can arrive well after the text. It
1559        # must be read here: left in the buffer, it would be taken as the
1560        # reply to the next command, and every reply after that would be
1561        # read one byte late.
1562        start = time.time()
1563        message = bytearray()
1564        flag_index = -1
1565        line_end = -1
1566
1567        while time.time() - start < timeout:
1568            n_waiting = self.port.in_waiting
1569            if n_waiting:
1570                message.extend(self.port.read(n_waiting))
1571                flag_index = message.find(b"!")
1572                if flag_index >= 0:
1573                    line_end = message.find(b"\n", flag_index)
1574                if line_end >= 0 and len(message) > line_end + 1:
1575                    break
1576            time.sleep(0.01)
1577        if line_end < 0 or len(message) <= line_end + 1:
1578            raise PulsePalError(
1579                "Pulse Pal did not report the result of formatting "
1580                f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}"
1581            )
1582        self._reply_pending = None  # The whole reply is read (see _write_raw())
1583        confirm_byte = message[line_end + 1]
1584
1585        text = bytes(message[:flag_index]).decode(
1586            "ascii", errors="replace"
1587        ).rstrip()
1588        if text:
1589            print(text)
1590
1591        # The device has loaded its own defaults. They are this class's, but the device keeps no record of
1592        # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the
1593        # MATLAB class does, and this object describes the device again.
1594        self.set_default_params()
1595        if confirm_byte != 1:
1596            raise PulsePalError(
1597                "Pulse Pal could not format its microSD card."
1598            )
1599        return True

Format the device's microSD card.

Erases every program saved on the device and resets its parameters to the defaults. By default, asks at the console for confirmation first; a script that runs on its own, or an AI agent, passes confirm=False.

Requires Pulse Pal hardware v3 or newer.

Arguments:
  • confirm: If True, ask at the console before anything is erased. If False, format at once.
  • timeout: Seconds to wait for the device to report that formatting has finished.
Returns:

True once the card has been formatted, or False if the user declines the confirmation prompt.

Raises:
  • PulsePalError: If the connected hardware is older than v3, if the device reports that formatting failed, or if it does not report a result within timeout seconds.
def gui(self, block=None, theme=None):
1601    def gui(self, block=None, theme=None):
1602        """Open the Pulse Pal parameter GUI, or focus an open one.
1603
1604        The GUI edits its own copy of the parameters, and loads them to
1605        the device when its 'Load to Device' button is clicked. The
1606        window closes automatically when the device is closed or
1607        deleted.
1608
1609        Calling this while the GUI is already open focuses the existing
1610        window rather than opening a second one.
1611
1612        Args:
1613            block: If `True`, the call returns when the GUI is closed. If
1614                `False`, the call returns immediately, and the host
1615                application must run the Tk event loop. If `None`, the
1616                GUI blocks only when the host does not already provide a
1617                Tk event loop, e.g. when launched from a script.
1618            theme: `"light"` or `"dark"` to select the color theme, or
1619                `None` to match the desktop theme. Passing a theme to an
1620                already-open GUI recolors it in place.
1621
1622        Returns:
1623            The `pulsepal.gui.PulsePalGUI` instance driving the window.
1624
1625        Raises:
1626            ValueError: If the theme name is not recognized.
1627        """
1628        gui = getattr(self, "_gui", None)
1629        if gui is not None and not gui.is_closed:
1630            if theme is not None:
1631                gui.set_theme(theme)
1632            gui.focus()
1633            return gui
1634
1635        from .gui import PulsePalGUI
1636
1637        gui = PulsePalGUI(self, theme=theme)
1638        self._gui = gui
1639        gui.start(block=block)
1640        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 pulsepal.gui.PulsePalGUI instance driving the window.

Raises:
  • ValueError: If the theme name is not recognized.
def close(self):
1646    def close(self):
1647        """Close the connection to the device, and the GUI if open.
1648
1649        The device stops all output channels when the client disconnects,
1650        and shows its own name on its screen again. Safe to call more
1651        than once; later calls do nothing. Called automatically when
1652        leaving a `with` block and when the object is garbage collected,
1653        so pulse trains also stop when the last reference to the object
1654        goes, e.g. when a function that created it returns.
1655        """
1656        self._close(send_disconnect=True)

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

The device stops all output channels when the client disconnects, and shows its own name on its screen again. Safe to call more than once; later calls do nothing. Called automatically when leaving a with block and when the object is garbage collected, so pulse trains also stop when the last reference to the object goes, e.g. when a function that created it returns.

@dataclass
class DeviceInfo:
220@dataclass
221class DeviceInfo:
222    """Properties of the connected Pulse Pal device.
223
224    An instance is created for each connection and populated during the
225    handshake in `PulsePalDevice.__init__`. It is available as
226    `PulsePalDevice.info`. Devices running firmware v21 report only a
227    firmware version, so the remaining fields are filled in with the
228    known values for Pulse Pal hardware v2.
229
230    ```python
231    print(P.info.firmware_version)
232    ```
233    """
234
235    output_parameter_names: tuple = tuple(name for name, _ in _OUTPUT_PARAMETERS.values())
236    """Output parameter names, ordered by parameter code.
237
238    Each is the name of a parameter list on `PulsePalDevice`, and a key of
239    the dict `PulsePalDevice.export_params` returns.
240    """
241
242    trigger_modes: tuple = TRIGGER_MODES
243    """Names of the trigger modes the connected device has, accepted by
244    `PulsePalDevice.trigger_mode`. Pulse Pal 2 has no "Param Sync"."""
245
246    custom_train_targets: tuple = CUSTOM_TRAIN_TARGETS
247    """Names accepted by `PulsePalDevice.custom_train_target`."""
248
249    firmware_version: int = None
250    """Firmware version running on the connected device."""
251
252    hardware_version: int = None
253    """Hardware version of the connected device, e.g. `2` or `3`.
254
255    Reported by the device on firmware v22 and newer; assumed to be `2`
256    on older firmware.
257    """
258
259    circuit_board_revision: int = None
260    """Revision of the device's circuit board, 0-31.
261
262    A Pulse Pal 3 board encodes its revision on five pins, which the
263    firmware reads at startup. A board that encodes none reports `0`, as
264    does Pulse Pal 2; firmware older than v22 cannot report one, so it is
265    also `0` there. To record exactly which hardware ran an experiment,
266    copy `hardware_version` and `circuit_board_revision` to your data.
267    """
268
269    serial_number: str = None
270    """USB serial number of the device, e.g. `"20345420"`: the Teensy's,
271    unique to each Pulse Pal 3, and the same whichever firmware it runs.
272
273    Connect by it, wherever the device is plugged in, with
274    `PulsePalDevice(serial_number="20345420")`. To record exactly which unit ran an
275    experiment, copy it to your data. Pulse Pal 2 has none, and
276    firmware older than v22 cannot report one: `""`.
277    """
278
279    max_custom_pulses: int = None
280    """Maximum number of pulses in a single custom pulse train."""
281
282    n_custom_pulse_trains: int = None
283    """Number of custom pulse trains the device can store."""
284
285    cycle_frequency: float = None
286    """Update frequency of the device's hardware timer, in Hz.
287
288    All time parameters are rounded to a whole number of these cycles,
289    so this sets the timing resolution of the device.
290    """
291
292    cycle_period_us: float = None
293    """Update period of the device's hardware timer, in microseconds."""
294
295    min_pulse_width_us: float = None
296    """Shortest pulse phase, inter-pulse interval and pulse train duration,
297    and shortest time between custom pulses, in microseconds.
298
299    One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch
300    every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose
301    trigger channels read their inputs once per cycle, so that a one cycle
302    pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3
303    with firmware from before October 2026, can miss a pulse shorter than
304    100 us from a Pulse Pal 3. Custom pulse times are multiples of it.
305    """
306
307    max_time: float = MAX_TIME
308    """Longest time parameter or custom pulse time, in seconds. See
309    `MAX_TIME`."""

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: tuple = ('is_biphasic', 'phase1_voltage', 'phase2_voltage', 'phase1_duration', 'inter_phase_interval', 'phase2_duration', 'inter_pulse_interval', 'burst_duration', 'inter_burst_interval', 'pulse_train_duration', 'pulse_train_delay', 'link_trigger_channel1', 'link_trigger_channel2', 'custom_train_id', 'custom_train_target', 'custom_train_loop', 'resting_voltage', 'continuous_loop'), trigger_modes: tuple = ('Normal', 'Toggle', 'Gated', 'Param Sync'), custom_train_targets: tuple = ('Pulses', 'Bursts'), firmware_version: int = None, hardware_version: int = None, circuit_board_revision: int = None, serial_number: str = None, max_custom_pulses: int = None, n_custom_pulse_trains: int = None, cycle_frequency: float = None, cycle_period_us: float = None, min_pulse_width_us: float = None, max_time: float = 9999.9999)
output_parameter_names: tuple = ('is_biphasic', 'phase1_voltage', 'phase2_voltage', 'phase1_duration', 'inter_phase_interval', 'phase2_duration', 'inter_pulse_interval', 'burst_duration', 'inter_burst_interval', 'pulse_train_duration', 'pulse_train_delay', 'link_trigger_channel1', 'link_trigger_channel2', 'custom_train_id', 'custom_train_target', 'custom_train_loop', 'resting_voltage', 'continuous_loop')

Output parameter names, ordered by parameter code.

Each is the name of a parameter list on PulsePalDevice, and a key of the dict PulsePalDevice.export_params returns.

trigger_modes: tuple = ('Normal', 'Toggle', 'Gated', 'Param Sync')

Names of the trigger modes the connected device has, accepted by PulsePalDevice.trigger_mode. Pulse Pal 2 has no "Param Sync".

custom_train_targets: tuple = ('Pulses', 'Bursts')
firmware_version: int = None

Firmware version running on the connected device.

hardware_version: int = None

Hardware version 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.

circuit_board_revision: int = None

Revision of the device's circuit board, 0-31.

A Pulse Pal 3 board encodes its revision on five pins, which the firmware reads at startup. A board that encodes none reports 0, as does Pulse Pal 2; firmware older than v22 cannot report one, so it is also 0 there. To record exactly which hardware ran an experiment, copy hardware_version and circuit_board_revision to your data.

serial_number: str = None

USB serial number of the device, e.g. "20345420": the Teensy's, unique to each Pulse Pal 3, and the same whichever firmware it runs.

Connect by it, wherever the device is plugged in, with PulsePalDevice(serial_number="20345420"). To record exactly which unit ran an experiment, copy it to your data. Pulse Pal 2 has none, and firmware older than v22 cannot report one: "".

max_custom_pulses: int = None

Maximum number of pulses in a single custom pulse train.

n_custom_pulse_trains: int = None

Number of custom pulse trains the device can store.

cycle_frequency: float = None

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

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

cycle_period_us: float = None

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

min_pulse_width_us: float = None

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

One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose trigger channels read their inputs once per cycle, so that a one cycle pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3 with firmware from before October 2026, can miss a pulse shorter than 100 us from a Pulse Pal 3. Custom pulse times are multiples of it.

max_time: float = 9999.9999

Longest time parameter or custom pulse time, in seconds. See MAX_TIME.

@dataclass
class DeviceStatus:
312@dataclass
313class DeviceStatus:
314    """What the device is doing, and what has happened to it, from
315    `PulsePalDevice.status`.
316
317    The counters count from the previous call to `PulsePalDevice.status`,
318    by this or any earlier connection, or from the device's startup: each
319    call clears them, and reports what happened since the one before.
320    """
321
322    playing: list
323    """Numbers of the output channels playing, e.g. `[1, 3]`: from a
324    channel's start, its pulse train delay included, until its train
325    ends."""
326
327    trigger_edges: list
328    """Rising edges on each trigger channel, indexed by trigger channel
329    number (index 0 is unused), whatever its trigger mode and whether or
330    not they started an output channel."""
331
332    channel_starts: list
333    """Times each output channel started, indexed by channel number
334    (index 0 is unused): by a trigger channel, `PulsePalDevice.trigger`
335    or the joystick menu. A trigger that a channel ignored, because it
336    was already playing, is not a start."""
337
338    comm_failures: int
339    """Commands that reached the device only in part, after which it
340    showed COMM. FAILURE! and waited for its joystick to be clicked."""

What the device is doing, and what has happened to it, from PulsePalDevice.status.

The counters count from the previous call to PulsePalDevice.status, by this or any earlier connection, or from the device's startup: each call clears them, and reports what happened since the one before.

DeviceStatus( playing: list, trigger_edges: list, channel_starts: list, comm_failures: int)
playing: list

Numbers of the output channels playing, e.g. [1, 3]: from a channel's start, its pulse train delay included, until its train ends.

trigger_edges: list

Rising edges on each trigger channel, indexed by trigger channel number (index 0 is unused), whatever its trigger mode and whether or not they started an output channel.

channel_starts: list

Times each output channel started, indexed by channel number (index 0 is unused): by a trigger channel, PulsePalDevice.trigger or the joystick menu. A trigger that a channel ignored, because it was already playing, is not a start.

comm_failures: int

Commands that reached the device only in part, after which it showed COMM. FAILURE! and waited for its joystick to be clicked.

TRIGGER_MODES = ('Normal', 'Toggle', 'Gated', 'Param Sync')

Names of the trigger modes, in order of their code on the device (0-3). "Param Sync" is on Pulse Pal 3 only. See PulsePalDevice.trigger_mode.

CUSTOM_TRAIN_TARGETS = ('Pulses', 'Bursts')

Names of the custom train targets, in order of their code on the device. See PulsePalDevice.custom_train_target.

MAX_TIME = 9999.9999

The longest time parameter or custom pulse time, in seconds: the most the device's joystick menu shows and edits (four digits before the point).