pulsepal

Python interface for Pulse Pal, an open source pulse train generator, and for Wave Pal and Synth Pal, two alternative firmwares for Pulse Pal 3.

from pulsepal import PulsePalDevice

with PulsePalDevice("COM3") as P:   # Replace COM3 with Pulse Pal's port
    P.phase1_voltage[1] = 5
    P.trigger(1)
Device firmware Class Module
Pulse Pal: pulse trains PulsePalDevice pulsepal.pulse_pal
Wave Pal: sampled waveforms, streamed from the microSD card WavePalDevice pulsepal.wave_pal
Synth Pal: sine, triangle, square and sawtooth waves, white noise, and fixed voltages SynthPalDevice pulsepal.synth_pal

The three classes work the same way. Settings are lists indexed by channel number (index 0 is unused), and assigning to one programs the device at once; trigger() starts channels and stop() stops them; set_default_params() programs the defaults; export_params() and import_params() record and restore every setting; print(device) shows every setting; and they raise PulsePalError when something fails. Each class names the firmware a device runs when it is not its own.

PulsePalDevice.serialportlist() lists the USB serial ports, to find the device's.

This file is part of the Sanworks PulsePal repository, released under the GNU General Public License version 3.

 1"""
 2Python interface for [Pulse Pal](https://sites.google.com/site/pulsepalwiki/),
 3an open source pulse train generator, and for Wave Pal and Synth Pal, two
 4alternative firmwares for Pulse Pal 3.
 5
 6```python
 7from pulsepal import PulsePalDevice
 8
 9with PulsePalDevice("COM3") as P:   # Replace COM3 with Pulse Pal's port
10    P.phase1_voltage[1] = 5
11    P.trigger(1)
12```
13
14| Device firmware | Class | Module |
15|---|---|---|
16| Pulse Pal: pulse trains | `PulsePalDevice` | `pulsepal.pulse_pal` |
17| Wave Pal: sampled waveforms, streamed from the microSD card | `WavePalDevice` | `pulsepal.wave_pal` |
18| Synth Pal: sine, triangle, square and sawtooth waves, white noise, and fixed voltages | `SynthPalDevice` | `pulsepal.synth_pal` |
19
20The three classes work the same way. Settings are lists indexed by
21channel number (index 0 is unused), and assigning to one programs the
22device at once; `trigger()` starts channels and `stop()` stops them;
23`set_default_params()` programs the defaults; `export_params()` and
24`import_params()` record and restore every setting; `print(device)` shows
25every setting; and they raise `PulsePalError` when something fails. Each
26class names the firmware a device runs when it is not its own.
27
28`PulsePalDevice.serialportlist()` lists the USB serial ports, to find the
29device's.
30
31This file is part of the Sanworks PulsePal repository, released under the
32GNU General Public License version 3.
33"""
34
35from ._common import PulsePalError
36from .pulse_pal import PulsePalDevice
37from .synth_pal import SynthPalDevice
38from .wave_pal import WavePalDevice
39
40__version__ = "3.0.0"
41"""Version of this package."""
42
43__all__ = ["PulsePalDevice", "WavePalDevice", "SynthPalDevice", "PulsePalError", "__version__"]
44__docformat__ = "google"
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.

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:
  • PulsePalError: If custom_train_id is out of range, there are no samples, or more than DeviceInfo.max_custom_pulses, sampling_period is not a positive multiple of DeviceInfo.min_pulse_width_us, the last sample would start after MAX_TIME, or the device does not acknowledge the command.
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.

class WavePalDevice:
 264class WavePalDevice:
 265    """A class to control a Wave Pal on a USB serial port.
 266
 267    Creating an instance opens the serial port, checks that the device
 268    runs Wave Pal firmware, reads its properties into
 269    `WavePalDevice.info`, shows "PYTHON Connected" on the device's
 270    screen, stops any playback and programs the default settings (see
 271    `WavePalDevice.set_default_params`).
 272
 273    ```python
 274    from pulsepal import WavePalDevice
 275
 276    W = WavePalDevice("COM3")
 277    W.load_waveform(1, [0, 1, 2, 3, 4, 5, 0], 1)   # waveform 1, played by channel 1
 278    W.trigger(1)
 279    W.close()
 280    ```
 281
 282    Replace "COM3" with the device's USB serial port name, which
 283    `WavePalDevice.serialportlist` lists. `WavePalDevice` is also a
 284    context manager, which closes the port on exit.
 285
 286    Closing the connection stops playback, as it does on Pulse Pal, and
 287    puts the device's own name back on its screen. The device keeps its
 288    settings and waveforms, so TTL triggers still play them.
 289    """
 290
 291    port: "serial.Serial"
 292    """The open `serial.Serial` port connected to the device."""
 293
 294    info: DeviceInfo
 295    """Properties of the connected device. See `DeviceInfo`."""
 296
 297    _CURRENT_FIRMWARE_VERSION = 1
 298    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 299
 300    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 301    _OP_HANDSHAKE = _common.OP_HANDSHAKE
 302    _OP_DISCONNECT = 81
 303    _OP_SET_CLIENT_NAME = 89
 304    _OP_SET_SCREEN_SAVER = 99
 305    _OP_HARDWARE_REVISION = 100
 306    _OP_HARDWARE_INFO = ord("N")
 307    _OP_SET_SAMPLING_RATE = ord("S")
 308    _OP_SET_OUTPUT_RANGE = ord("R")
 309    _OP_LOAD_WAVEFORM = ord("L")
 310    _OP_SET_CURRENT_WAVEFORMS = ord("C")
 311    _OP_TRIGGER = ord("P")
 312    _OP_STOP = ord("X")
 313    _OP_SET_FIXED_VOLTAGE = ord("!")
 314    _OP_SET_LOOP_MODE = ord("O")
 315    _OP_SET_LOOP_DURATION = ord("D")
 316    _OP_SET_TRIGGER_MODE = ord("T")
 317    _OP_SET_TRIGGER_LINKS = ord("I")
 318    _OP_GET_STATUS = ord("G")
 319    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 320
 321    _HANDSHAKE_REPLY = 87  # 'W'
 322    _HARDWARE_INFO_FORMAT = "<BBIIIIB"
 323    _STATUS_FORMAT = "<B4I4II4BBB4B"
 324    _DAC_BITMAX = 65535
 325    _UINT32_MAX = 2**32 - 1
 326    _ALL_CHANNELS = 0x0F
 327    # The ops that carry a setting, which _discard_lost_reply() sends again if the reply to one is lost
 328    _SETTING_OPS = frozenset(map(ord, "SRCODTI"))
 329
 330    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_command())
 331
 332    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 333        """Open a connection to a Wave Pal.
 334
 335        Args:
 336            port_name: USB serial port of the device, such as `COM3` on
 337                Windows or `/dev/ttyACM0` on Linux.
 338            serial_number: Instead of `port_name`, the device's USB serial
 339                number (`DeviceInfo.serial_number`), which finds its port
 340                wherever it is plugged in.
 341            timeout: Serial read timeout, in seconds. Loading a long
 342                waveform onto a slow microSD card can take a few seconds.
 343
 344        Firmware newer than this module knows is used with a warning: new
 345        firmware only adds commands. Update the package to use what is new.
 346
 347        Raises:
 348            PulsePalError: If the device does not reply to the handshake, or
 349                runs other firmware.
 350            serial.SerialException: If the serial port cannot be opened,
 351                e.g. because another program has it open (on Linux and
 352                macOS, another session of these classes, which lock it).
 353        """
 354        self._closed = True
 355        self.info = DeviceInfo()
 356        self._sampling_rate = None
 357        self._output_range = None
 358        self._waveforms = []  # Indexed by waveform number, once the device has said how many it stores
 359        self._current_waveform = ChannelSettings(
 360            "current_waveform", [0] * 4, self, "_apply_current_waveform")
 361        self._loop_mode = ChannelSettings(
 362            "loop_mode", [False] * 4, self, "_apply_loop_mode")
 363        self._loop_duration = ChannelSettings(
 364            "loop_duration", [0.0] * 4, self, "_apply_loop_duration")
 365        self._trigger_mode = ChannelSettings(
 366            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 367        self._link_trigger_channel1 = ChannelSettings(
 368            "link_trigger_channel1", [False] * 4, self,
 369            "_apply_trigger_channel1_links")
 370        self._link_trigger_channel2 = ChannelSettings(
 371            "link_trigger_channel2", [False] * 4, self,
 372            "_apply_trigger_channel2_links")
 373
 374        port_name = _common.port_name_from(port_name, serial_number, "WavePalDevice")
 375        self.port = serial.Serial(
 376            port_name,
 377            self._BAUD_RATE,
 378            timeout=timeout,
 379            rtscts=True,
 380            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 381            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 382            # it, such as another of these classes, gets an error instead
 383            exclusive=True,
 384        )
 385        self._closed = False
 386        try:
 387            # Discard anything left in the buffer by an earlier session
 388            self.port.reset_input_buffer()
 389            self._handshake()
 390            self._read_hardware_info()
 391            self._waveforms = [None] * (self.info.n_waveforms + 1)
 392            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 393            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 394            self.stop()
 395            self.set_default_params()
 396        except BaseException:
 397            # Op 81 means something else to other devices, so it is sent
 398            # only once the device has identified itself as a Wave Pal
 399            self._close(send_disconnect=self.info.firmware_version is not None)
 400            raise
 401
 402    @staticmethod
 403    def serialportlist(ports_to_list="available"):
 404        """Return the names of the USB serial ports on this computer.
 405
 406        Called on the class, without connecting to a device, to find the
 407        port name to pass to `WavePalDevice`.
 408
 409        Args:
 410            ports_to_list: `available` to list only the ports that are
 411                not already in use, or `all` to list every USB serial
 412                port. Not case sensitive.
 413
 414        Returns:
 415            Sorted list of port names, such as `["COM3", "COM7"]`.
 416
 417        Raises:
 418            PulsePalError: If `ports_to_list` is not `available` or `all`.
 419        """
 420        return _common.serialportlist(ports_to_list)
 421
 422    # ------------------------------------------------------------------
 423    # Settings
 424    # ------------------------------------------------------------------
 425
 426    def set_default_params(self):
 427        """Program the default settings on the device.
 428
 429        The defaults are both trigger channels in normal mode, a 10 kHz
 430        sampling rate, the -10 V to 10 V output range, loop mode off with
 431        loop durations of 0, no output channel linked to a trigger channel
 432        (so a TTL starts nothing until a script links it), and no current
 433        waveform on any channel. They match the settings the device starts with. The
 434        trigger modes are sent first: a trigger channel leaving param sync
 435        mode makes what was stored for its next edge current, and the
 436        settings after it then apply at once.
 437
 438        Loaded waveforms are kept, and loaded again if the output range
 439        changes (see `WavePalDevice.output_range`).
 440
 441        Raises:
 442            PulsePalError: If a loaded waveform does not fit the default
 443                output range, -10 V to 10 V.
 444        """
 445        self.trigger_mode = ["Normal"] * 2
 446        self.sampling_rate = 10000
 447        self.output_range = "-10V:10V"
 448        self.loop_mode = [False] * 4
 449        self.loop_duration = [0] * 4
 450        self._set_trigger_links([False] * 4, [False] * 4)
 451        self.current_waveform = [0] * 4
 452
 453    @property
 454    def sampling_rate(self):
 455        """Sampling rate of all output channels, in Hz.
 456
 457        A whole number from 1 to `DeviceInfo.max_sampling_rate`. It can be
 458        changed during playback. The rate played can differ slightly from
 459        the rate set: see `WavePalDevice.actual_sampling_rate`.
 460        """
 461        return self._sampling_rate
 462
 463    @sampling_rate.setter
 464    def sampling_rate(self, rate):
 465        rate_hz = self._normalize_sampling_rate(rate)
 466        # Loop durations are sent in samples, so check that they still fit
 467        # before anything is changed
 468        loop_samples = None
 469        if self._sampling_rate is not None:
 470            loop_samples = self._durations_to_samples(
 471                self._loop_duration[1:], rate_hz)
 472        self._write_command(self._OP_SET_SAMPLING_RATE,
 473                            struct.pack("<I", rate_hz))
 474        self._read_ack("setting sampling_rate")
 475        self._sampling_rate = rate_hz
 476        if loop_samples is not None:
 477            self._send_loop_duration_samples(loop_samples)
 478
 479    @property
 480    def actual_sampling_rate(self):
 481        """The sampling rate the device plays, in Hz.
 482
 483        The device divides `DeviceInfo.sample_clock_hz` (24 MHz) by a
 484        whole number, so the rate played is the nearest one of those to
 485        `WavePalDevice.sampling_rate`. For example, 44100 Hz plays at
 486        44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz,
 487        25 kHz or 100 kHz, play exactly.
 488        """
 489        return self._actual_rate(self._sampling_rate)
 490
 491    @property
 492    def output_range(self):
 493        """Voltage range of all output channels.
 494
 495        One of `DeviceInfo.output_ranges`: `"0V:5V"`, `"0V:10V"`,
 496        `"-5V:5V"` or `"-10V:10V"`. The smallest range that fits the
 497        waveforms gives the finest voltage steps.
 498
 499        Changing the range stops playback, sets all outputs to 0 V, and
 500        loads the waveforms again, encoded for the new range. It raises
 501        an error, and changes nothing, if a loaded waveform does not fit
 502        the new range, or if a trigger channel is in param sync mode,
 503        where the loads would wait for a TTL edge.
 504        """
 505        return self._output_range
 506
 507    @output_range.setter
 508    def output_range(self, range_name):
 509        name = self._check_output_range(range_name, self._trigger_mode[1:])
 510        changed = name != self._output_range
 511        range_index = list(OUTPUT_RANGES).index(name)
 512        self._write_command(self._OP_SET_OUTPUT_RANGE, bytes([range_index]))
 513        self._read_ack("setting output_range")
 514        self._output_range = name
 515        if changed:
 516            # The device unloads the waveforms when the range changes,
 517            # because their samples encode voltages in the old range. The
 518            # channels keep their current waveforms.
 519            waveforms = self._waveforms
 520            self._waveforms = [None] * len(waveforms)
 521            for number, waveform in enumerate(waveforms):
 522                if waveform is not None:
 523                    self.load_waveform(number, waveform)
 524
 525    @property
 526    def current_waveform(self):
 527        """The waveform each output channel plays, by waveform number.
 528
 529        Indexed by channel number (see "Channel settings" above). Each
 530        value is the number of a waveform loaded with
 531        `WavePalDevice.load_waveform`, 1 to `DeviceInfo.n_waveforms`, or
 532        `0` for none, the default: a channel with no waveform ignores
 533        triggers. Several channels can play the same waveform.
 534
 535        A channel that is playing finishes its waveform: the new one plays
 536        from the channel's next start. In param sync mode, a change waits
 537        for the next rising edge on the param sync trigger channel (see
 538        `WavePalDevice.trigger_mode`).
 539
 540        ```python
 541        W.current_waveform[1] = 2            # channel 1 plays waveform 2
 542        W.current_waveform = [1, 1, 1, 1]    # all four play waveform 1
 543        ```
 544        """
 545        return self._current_waveform
 546
 547    @current_waveform.setter
 548    def current_waveform(self, values):
 549        self._current_waveform._assign(values)
 550
 551    @property
 552    def loop_mode(self):
 553        """Whether each output channel loops its waveform.
 554
 555        Indexed by channel number (see "Channel settings" above). `True`
 556        repeats the waveform until `WavePalDevice.loop_duration` has
 557        elapsed, or until stopped if the loop duration is 0. `False` plays
 558        it once. A change applies to playback in progress.
 559        """
 560        return self._loop_mode
 561
 562    @loop_mode.setter
 563    def loop_mode(self, values):
 564        self._loop_mode._assign(values)
 565
 566    @property
 567    def loop_duration(self):
 568        """How long each output channel loops its waveform, in seconds.
 569
 570        Indexed by channel number (see "Channel settings" above). Applies
 571        in loop mode only, and counts from the trigger. The channel stops
 572        when it has elapsed, part way through the waveform if need be. `0`
 573        loops until the channel is stopped. Durations are rounded to a
 574        whole number of samples, and a nonzero duration lasts at least
 575        one sample.
 576        """
 577        return self._loop_duration
 578
 579    @loop_duration.setter
 580    def loop_duration(self, values):
 581        self._loop_duration._assign(values)
 582
 583    @property
 584    def trigger_mode(self):
 585        """How each trigger channel acts on the output channels linked to it.
 586
 587        Indexed by trigger channel number, 1 or 2 (index 0 is unused), as
 588        in Pulse Pal. A TTL edge on a trigger channel acts on the output
 589        channels linked to it (`WavePalDevice.link_trigger_channel1`,
 590        `WavePalDevice.link_trigger_channel2`):
 591
 592        - `"Normal"`: a rising edge starts the linked channels' waveforms.
 593          Channels that are playing ignore it.
 594        - `"Toggle"`: a rising edge starts the linked channels, or stops
 595          those that are playing.
 596        - `"Gated"`: a rising edge starts the linked channels, and a
 597          falling edge stops them, unless the other trigger channel is also
 598          gated, linked to them, and still high. With loop mode on and a
 599          loop duration of 0, a waveform plays for exactly as long as the
 600          TTL is high.
 601        - `"Param Sync"`: a rising edge starts and stops nothing (the
 602          channel's links are ignored). It makes the waveforms loaded,
 603          and the `WavePalDevice.current_waveform` set, since the previous
 604          edge current. This is how the next trial's waveforms are loaded
 605          during the current trial, and played from the instant it starts.
 606        - `"Master"`: a rising edge starts the linked channels, and
 607          restarts those that are playing from their first sample.
 608
 609        The first four are Pulse Pal's trigger modes. Names are not case
 610        sensitive.
 611
 612        While a trigger channel is in param sync mode,
 613        `WavePalDevice.load_waveform` loads into a second memory block
 614        that the device keeps for each waveform. Channels go on playing
 615        the waveform it replaces, and triggers still start it, until the
 616        edge. A channel that is playing at the edge finishes what it plays,
 617        and plays the new waveform from its next start. A later load of the
 618        same waveform before the edge replaces the earlier one, and stops
 619        any channel still playing the waveform from before the previous
 620        edge, since the device reuses that memory block. Changes to
 621        `WavePalDevice.current_waveform` wait for the edge in the same
 622        way. Other settings apply at once.
 623
 624        When neither trigger channel is in param sync mode any more, what
 625        was waiting for an edge becomes current at once, so
 626        `WavePalDevice.waveforms` and `WavePalDevice.current_waveform`
 627        describe the device again. (Pulse Pal and Synth Pal discard their
 628        stored parameters and settings then; Pulse Pal makes its stored
 629        custom trains current, as this class does its waveforms.)
 630        `WavePalDevice.output_range` cannot change
 631        in param sync mode. To make the waveforms current on the same edge
 632        that starts the channels, wire the TTL to the other trigger channel
 633        as well: the param sync edge is handled first.
 634
 635        ```python
 636        W.trigger_mode[2] = "Param Sync"      # sent at once
 637        W.load_waveform(1, next_trial_wave)   # waits for trigger channel 2's next rising edge
 638        ```
 639        """
 640        return self._trigger_mode
 641
 642    @trigger_mode.setter
 643    def trigger_mode(self, values):
 644        self._trigger_mode._assign(values)
 645
 646    @property
 647    def link_trigger_channel1(self):
 648        """Whether each output channel is triggered by trigger channel 1.
 649
 650        Indexed by channel number (see "Channel settings" above). By
 651        default no output channel is linked to either trigger channel, so
 652        a TTL starts nothing until a script links it.
 653        """
 654        return self._link_trigger_channel1
 655
 656    @link_trigger_channel1.setter
 657    def link_trigger_channel1(self, values):
 658        self._link_trigger_channel1._assign(values)
 659
 660    @property
 661    def link_trigger_channel2(self):
 662        """Whether each output channel is triggered by trigger channel 2.
 663
 664        Indexed by channel number (see "Channel settings" above).
 665        """
 666        return self._link_trigger_channel2
 667
 668    @link_trigger_channel2.setter
 669    def link_trigger_channel2(self, values):
 670        self._link_trigger_channel2._assign(values)
 671
 672    def set_screen_saver(self, enabled, timeout=1800):
 673        """Switch the device's screen saver on or off, and set its timeout.
 674
 675        With the screen saver on, the device dims its screen once it has
 676        been left alone for `timeout` seconds: no command from a computer,
 677        no rising edge on a trigger channel, and no joystick click or
 678        push. The next of these brings the screen back. The device keeps
 679        both settings in its EEPROM, shared with Pulse Pal and Synth Pal
 680        firmware, and saves them once no channel is playing. A new device
 681        starts with the screen saver on and 1800 s.
 682
 683        Args:
 684            enabled: `True` to switch the screen saver on, `False` to
 685                switch it off.
 686            timeout: Seconds without activity before the screen dims, 1 to
 687                65535. It is sent with every call, so leaving it out sets
 688                1800 s.
 689
 690        Raises:
 691            PulsePalError: If a value is out of range.
 692        """
 693        state = to_bool(enabled, "enabled")
 694        if isinstance(timeout, (bool, np.bool_)) \
 695                or not isinstance(timeout, numbers.Integral) \
 696                or not 1 <= timeout <= 65535:
 697            raise PulsePalError(
 698                "timeout must be a whole number of seconds from 1 to 65535. "
 699                f"Received {timeout!r}."
 700            )
 701        self._write_command(self._OP_SET_SCREEN_SAVER,
 702                            struct.pack("<BH", int(state), int(timeout)))
 703        self._read_ack("set_screen_saver()")
 704
 705    def export_params(self):
 706        """Return every setting, as a dict of plain values and lists.
 707
 708        Keyed by setting name: `"sampling_rate"` and `"output_range"`, then
 709        the channel settings, each with one value per channel and no
 710        unused index 0. It holds only numbers, booleans and names, so it
 711        can be saved with `json` and logged with your data. The waveforms
 712        are not part of it: `WavePalDevice.waveforms` holds them.
 713
 714        ```python
 715        import json
 716        with open("session_settings.json", "w") as f:
 717            json.dump(W.export_params(), f)
 718        ```
 719        """
 720        params = {"sampling_rate": self._sampling_rate, "output_range": self._output_range}
 721        for name in _CHANNEL_SETTINGS:
 722            params[name] = list(getattr(self, name))[1:]
 723        return params
 724
 725    def import_params(self, params):
 726        """Program the device with settings exported by `WavePalDevice.export_params`.
 727
 728        All of them are checked first, and nothing is sent if one is
 729        invalid. They are then sent in an order that lets each take
 730        effect: a trigger channel leaving param sync mode first, then the
 731        sampling rate, the output range and the channel settings, and a
 732        trigger channel entering param sync mode last. So
 733        `WavePalDevice.current_waveform` applies at once, unless a trigger
 734        channel was in param sync mode and stays in it: then it waits for
 735        the next edge, as an assignment would. Settings missing from
 736        `params` keep their values. A name that is not a setting is
 737        skipped with a warning, so that settings exported by a newer
 738        version of this package, which may have more, can still be
 739        imported.
 740
 741        The waveforms are not settings: load them with
 742        `WavePalDevice.load_waveform` first, since a current waveform must
 743        be a loaded one.
 744
 745        ```python
 746        import json
 747        with open("session_settings.json") as f:
 748            W.import_params(json.load(f))
 749        ```
 750
 751        Args:
 752            params: A dict of setting name to its value: a number for
 753                `"sampling_rate"`, a name for `"output_range"`, and one
 754                value per channel (a list, tuple or NumPy array) for the
 755                others, as `export_params` returns.
 756
 757        Raises:
 758            PulsePalError: If a value is invalid (nothing is sent), or the
 759                device rejects a setting.
 760        """
 761        names = {"sampling_rate", "output_range", *_CHANNEL_SETTINGS}
 762        unknown = [name for name in params if name not in names]
 763        if unknown:
 764            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
 765                          "WavePalDevice does not have.", stacklevel=2)
 766        new = {}
 767        for name in _CHANNEL_SETTINGS:
 768            if name in params:
 769                n = 2 if name == "trigger_mode" else self.info.n_channels
 770                new[name] = _common.one_value_per_channel(name, params[name], n)
 771        if "current_waveform" in new:
 772            new["current_waveform"] = self._normalize_current_waveform(new["current_waveform"])
 773        if "loop_mode" in new:
 774            new["loop_mode"] = [to_bool(value, "loop_mode") for value in new["loop_mode"]]
 775        if "loop_duration" in new:
 776            new["loop_duration"] = self._normalize_loop_durations(new["loop_duration"])
 777        for name in ("link_trigger_channel1", "link_trigger_channel2"):
 778            if name in new:
 779                new[name] = [to_bool(value, name) for value in new[name]]
 780        modes = list(self._trigger_mode)[1:]
 781        if "trigger_mode" in new:
 782            modes = [to_name(value, TRIGGER_MODES, "trigger mode") for value in new["trigger_mode"]]
 783        # The trigger modes the other settings are sent in: those of channels leaving param sync mode are sent
 784        # first, those of channels entering it last
 785        first_modes = [new_mode if (old == "Param Sync" and new_mode != "Param Sync") else old
 786                       for old, new_mode in zip(self._trigger_mode[1:], modes)]
 787        rate = self._sampling_rate
 788        if "sampling_rate" in params:
 789            rate = self._normalize_sampling_rate(params["sampling_rate"])
 790        range_name = self._output_range
 791        if "output_range" in params:
 792            range_name = self._check_output_range(params["output_range"], first_modes)
 793        self._durations_to_samples(new.get("loop_duration", self._loop_duration[1:]), rate)  # They fit at the rate
 794
 795        if first_modes != list(self._trigger_mode)[1:]:
 796            self.trigger_mode = first_modes
 797        if rate != self._sampling_rate:
 798            self.sampling_rate = rate
 799        if range_name != self._output_range:
 800            self.output_range = range_name
 801        for name in ("loop_mode", "loop_duration"):
 802            if name in new:
 803                setattr(self, name, new[name])
 804        if "link_trigger_channel1" in new or "link_trigger_channel2" in new:
 805            self._set_trigger_links(new.get("link_trigger_channel1", self._link_trigger_channel1[1:]),
 806                                    new.get("link_trigger_channel2", self._link_trigger_channel2[1:]))
 807        if "current_waveform" in new:
 808            self.current_waveform = new["current_waveform"]
 809        if modes != first_modes:
 810            self.trigger_mode = modes
 811
 812    @property
 813    def waveforms(self):
 814        """The waveforms loaded with `WavePalDevice.load_waveform`.
 815
 816        A list indexed by waveform number, 1 to `DeviceInfo.n_waveforms`,
 817        with index 0 unused. Each element is a read-only NumPy array of
 818        voltages, or `None` if this object has not loaded that waveform. In
 819        param sync mode, it holds the waveforms loaded since the last edge
 820        before the device plays them. `WavePalDevice.status` shows what
 821        the device itself holds.
 822        """
 823        return list(self._waveforms)
 824
 825    # ------------------------------------------------------------------
 826    # Waveforms and playback
 827    # ------------------------------------------------------------------
 828
 829    def load_waveform(self, waveform_number, waveform, channels=None):
 830        """Load one of the device's waveforms, and choose the channels that play it.
 831
 832        The samples are written to the device's microSD card, and the
 833        first `DeviceInfo.buffer_samples` of them are also kept in its RAM.
 834        Outside param sync mode, loading stops the channels playing that
 835        waveform, and the new one plays from their next start. Other
 836        channels keep playing. In param sync mode, the new waveform waits
 837        for the next rising edge on the param sync trigger channel, and
 838        channels go on playing the old one until then (see
 839        `WavePalDevice.trigger_mode`).
 840
 841        ```python
 842        t = np.arange(10000) / W.sampling_rate
 843        W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t), [1, 3])
 844        ```
 845
 846        Args:
 847            waveform_number: The waveform to load, 1 to
 848                `DeviceInfo.n_waveforms`.
 849            waveform: Voltages of the samples, played at
 850                `WavePalDevice.sampling_rate`. A list, tuple or NumPy
 851                array of 1 to `DeviceInfo.max_samples` values, all within
 852                `WavePalDevice.output_range`.
 853            channels: Output channels that play this waveform, from their
 854                next start: one channel number 1-4, or several as a list,
 855                tuple or NumPy array. It sets their
 856                `WavePalDevice.current_waveform`, and leaves the other
 857                channels' alone, so channels already playing this waveform
 858                play the new one. `None`, the default, changes no channel.
 859
 860        Raises:
 861            PulsePalError: If an argument is invalid, or the device could
 862                not store the waveform. The waveform is then left out of
 863                `WavePalDevice.waveforms`: outside param sync mode the
 864                device has unloaded it, and in param sync mode it plays the
 865                version made current at the last edge.
 866        """
 867        number = self._waveform_number(waveform_number)
 868        channel_list = None if channels is None else _common.channel_numbers(channels)
 869        samples = np.array(waveform, dtype=float).ravel()
 870        if not 1 <= samples.size <= self.info.max_samples:
 871            raise PulsePalError(
 872                f"A waveform must have 1 to {self.info.max_samples} samples. "
 873                f"Received {samples.size}."
 874            )
 875        codes = self._volts_to_codes(samples)
 876        self._waveforms[number] = None  # Until the device has stored it. Outside param sync mode, it unloads it first.
 877        self._write_command(
 878            self._OP_LOAD_WAVEFORM,
 879            struct.pack("<BI", number, samples.size) + codes.tobytes(),
 880        )
 881        self._read_ack("load_waveform()")
 882        samples.flags.writeable = False
 883        self._waveforms[number] = samples
 884        if channel_list:
 885            values = list(self._current_waveform[1:])
 886            for channel in channel_list:
 887                values[channel - 1] = number
 888            self._current_waveform._assign(values)
 889
 890    def trigger(self, channels):
 891        """Trigger output channels in software.
 892
 893        Idle channels start their current waveforms from the first
 894        sample, together. Channels that are playing ignore it, as Pulse
 895        Pal's channels ignore a soft trigger while they play a pulse train.
 896
 897        ```python
 898        W.trigger(1)
 899        W.trigger([2, 4])     # a list, tuple or NumPy array
 900        ```
 901
 902        Args:
 903            channels: Output channel number 1-4, or several as a list,
 904                tuple or NumPy array.
 905
 906        Raises:
 907            PulsePalError: If a channel has no waveform to play: its
 908                `WavePalDevice.current_waveform` is 0, or a waveform this
 909                object has not loaded. Nothing is sent.
 910        """
 911        channel_list = _common.channel_numbers(channels)
 912        for channel in channel_list:
 913            number = self._current_waveform[channel]
 914            if number == 0:
 915                raise PulsePalError(
 916                    f"Channel {channel} has no waveform to play. Set current_waveform[{channel}] to a loaded "
 917                    f"waveform's number, or load one with load_waveform(number, waveform, {channel})."
 918                )
 919            if self._waveforms[number] is None:
 920                raise PulsePalError(
 921                    f"Channel {channel} plays waveform {number}, which is not loaded. Load it with "
 922                    f"load_waveform({number}, waveform)."
 923                )
 924        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channel_list)]))
 925
 926    def stop(self, channels=None):
 927        """Stop playback. The stopped channels output 0 V.
 928
 929        Args:
 930            channels: Output channel number 1-4, or several as a list,
 931                tuple or NumPy array. `None` stops all channels.
 932        """
 933        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
 934        self._write_command(self._OP_STOP, bytes([bits]))
 935
 936    def set_fixed_voltage(self, channels, voltage):
 937        """Set output channels to a fixed voltage.
 938
 939        Stops playback on those channels. The voltage holds until the
 940        channel is triggered or stopped.
 941
 942        Args:
 943            channels: Output channel number 1-4, or several as a list,
 944                tuple or NumPy array.
 945            voltage: Voltage, within `WavePalDevice.output_range`.
 946
 947        Raises:
 948            PulsePalError: If the voltage is outside the output range, or
 949                the device rejects the command.
 950        """
 951        bits = _common.channel_bits(channels)
 952        code = int(self._volts_to_codes([voltage])[0])
 953        self._write_command(self._OP_SET_FIXED_VOLTAGE,
 954                            struct.pack("<BH", bits, code))
 955        self._read_ack("set_fixed_voltage()")
 956
 957    def status(self):
 958        """Read the device's playback state.
 959
 960        Returns:
 961            A `DeviceStatus`, with the channels that are playing, the
 962            number of samples in each waveform, underrun counts, the
 963            longest playback interrupt, each channel's current waveform,
 964            and what waits for a param sync edge.
 965        """
 966        self._write_command(self._OP_GET_STATUS)
 967        values = struct.unpack(
 968            self._STATUS_FORMAT,
 969            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
 970        )
 971        playing_bits = values[0]
 972        stored_load_bits = values[14]
 973        return DeviceStatus(
 974            playing=[ch for ch in range(1, 5)
 975                     if playing_bits & (1 << (ch - 1))],
 976            samples_loaded=[None, *values[1:5]],
 977            underruns=[None, *values[5:9]],
 978            longest_interrupt_us=values[9] / 1000,
 979            current_waveform=[None, *values[10:14]],
 980            stored_waveforms=[n for n in range(1, 5) if stored_load_bits & (1 << (n - 1))],
 981            stored_current_waveform=[None, *values[16:20]] if values[15] else None,
 982        )
 983
 984    def _playback_checksums(self):
 985        """For testing: what each channel played since it last started.
 986
 987        Returns two lists indexed by channel number, with index 0 unused:
 988        the number of samples played, and the sum of their DAC codes
 989        modulo 2**32. `tests/wavepal_hardware_test.py` compares them with
 990        the waveforms it loaded, to check every sample the device played.
 991        """
 992        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
 993        values = struct.unpack("<8I", self._read_raw(32))
 994        return [None, *values[:4]], [None, *values[4:]]
 995
 996    # ------------------------------------------------------------------
 997    # Connection
 998    # ------------------------------------------------------------------
 999
1000    def close(self):
1001        """Close the connection to the device.
1002
1003        The device stops playback on all channels, and shows its own name
1004        on its screen again, in place of "PYTHON Connected". It keeps its
1005        settings and waveforms, so TTL triggers still play them. Safe to
1006        call more than once. Called automatically when leaving a `with`
1007        block and when the object is garbage collected, so playback also
1008        stops when the last reference to the object goes.
1009
1010        """
1011        self._close(send_disconnect=True)
1012
1013    def _close(self, send_disconnect):
1014        """Close the connection. send_disconnect: tell the device first (op 81). False when the device may not
1015        run this firmware: op 81 means something else to other devices."""
1016        if getattr(self, "_closed", True):
1017            return
1018        self._closed = True
1019        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1020        try:
1021            if send_disconnect and self.port and self.port.is_open:
1022                self._write_command(self._OP_DISCONNECT)
1023        except Exception:
1024            pass  # The port may already be gone, e.g. the cable was unplugged
1025        finally:
1026            if self.port and self.port.is_open:
1027                self.port.close()
1028
1029    def __enter__(self):
1030        """Enter a `with` block, returning the connected device."""
1031        return self
1032
1033    def __exit__(self, exc_type, exc_value, traceback):
1034        """Close the port when leaving a `with` block.
1035
1036        Returns:
1037            `False`, so any exception raised in the block propagates.
1038        """
1039        self.close()
1040        return False
1041
1042    def __del__(self):
1043        """Close the port when the object is collected."""
1044        try:
1045            self.close()
1046        except Exception:
1047            # Destructors should not raise; the serial object may already be
1048            # gone during interpreter shutdown.
1049            pass
1050
1051    def __setattr__(self, name, value):
1052        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1053        _common.refuse_unknown_attribute())."""
1054        if name[0] != "_":
1055            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1056        super().__setattr__(name, value)
1057
1058    def __repr__(self):
1059        """Describe the device and its settings, e.g. with `print(W)`."""
1060        port = getattr(getattr(self, "port", None), "port", None)
1061        loaded = ", ".join(
1062            f"{number}: {'none' if w is None else f'{w.size} samples'}"
1063            for number, w in enumerate(self._waveforms) if number > 0
1064        )
1065        return (
1066            f"WavePalDevice on {port} (Wave Pal firmware "
1067            f"v{self.info.firmware_version})\n"
1068            f"sampling_rate: {self._sampling_rate} Hz\n"
1069            f"output_range: {self._output_range}\n"
1070            f"current_waveform: {list(self._current_waveform)}\n"
1071            f"loop_mode: {list(self._loop_mode)}\n"
1072            f"loop_duration: {list(self._loop_duration)}\n"
1073            f"trigger_mode: {list(self._trigger_mode)}\n"
1074            f"link_trigger_channel1: {list(self._link_trigger_channel1)}\n"
1075            f"link_trigger_channel2: {list(self._link_trigger_channel2)}\n"
1076            f"waveforms: {loaded}"
1077        )
1078
1079    # ------------------------------------------------------------------
1080    # Internals
1081    # ------------------------------------------------------------------
1082
1083    def _handshake(self):
1084        """Check that the device runs a supported Wave Pal firmware."""
1085        self._write_command(self._OP_HANDSHAKE)
1086        handshake = _common.read_handshake_reply(self.port)  # Skips a reply to an earlier session's command
1087        if handshake is None:
1088            raise PulsePalError(
1089                f"No reply from the device on {self.port.port}. Is it a "
1090                "Pulse Pal 3 running Wave Pal firmware?"
1091                f" {_common.NO_REPLY_HINT}"
1092            )
1093        reply, version = handshake
1094        if reply != self._HANDSHAKE_REPLY:
1095            if reply in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
1096                raise _common.other_firmware_error(reply, version, self.port.port, "Wave Pal")
1097            raise PulsePalError(
1098                "Incorrect handshake returned. Expected "
1099                f"{self._HANDSHAKE_REPLY}, received {reply}."
1100            )
1101        if version > self._CURRENT_FIRMWARE_VERSION:
1102            # New firmware only adds commands (see PROTOCOL.md), so this class still works with it
1103            warnings.warn(
1104                f"Wave Pal firmware v{version} is newer than this pulsepal package knows "
1105                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what is "
1106                "new.", stacklevel=3)
1107        self.info.firmware_version = version
1108
1109    def _read_hardware_info(self):
1110        self._write_command(self._OP_HARDWARE_INFO)
1111        (
1112            self.info.hardware_version,
1113            self.info.n_channels,
1114            self.info.max_samples,
1115            self.info.max_sampling_rate,
1116            self.info.buffer_samples,
1117            self.info.sample_clock_hz,
1118            self.info.n_waveforms,
1119        ) = struct.unpack(
1120            self._HARDWARE_INFO_FORMAT,
1121            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
1122        )
1123        self._write_command(self._OP_HARDWARE_REVISION)
1124        self.info.circuit_board_revision = self._read_raw(1)[0]
1125        self._write_command(_common.OP_SERIAL_NUMBER)
1126        self.info.serial_number = _common.read_serial_number(self._read_raw)
1127
1128    def _apply_current_waveform(self, values):
1129        waveform_numbers = self._normalize_current_waveform(values)
1130        self._write_command(self._OP_SET_CURRENT_WAVEFORMS, bytes(waveform_numbers))
1131        self._read_ack("setting current_waveform")
1132        return waveform_numbers
1133
1134    def _normalize_current_waveform(self, values):
1135        """Check new current waveforms for all channels, and return them as the list holds them."""
1136        waveform_numbers = []
1137        for channel, value in enumerate(values, start=1):
1138            if (isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Integral)
1139                    or not 0 <= value <= self.info.n_waveforms):
1140                raise PulsePalError(
1141                    f"current_waveform values are waveform numbers, 1-{self.info.n_waveforms}, or 0 for none. "
1142                    f"Received {value!r}."
1143                )
1144            value = int(value)
1145            # A channel left on a waveform whose load failed is not refused here: trigger() refuses it
1146            if value != 0 and value != self._current_waveform[channel] and self._waveforms[value] is None:
1147                raise PulsePalError(
1148                    f"Waveform {value} is not loaded. Load it with load_waveform({value}, waveform) first."
1149                )
1150            waveform_numbers.append(value)
1151        return waveform_numbers
1152
1153    def _apply_loop_mode(self, values):
1154        modes = [to_bool(value, "loop_mode") for value in values]
1155        self._write_command(self._OP_SET_LOOP_MODE, bytes(map(int, modes)))
1156        self._read_ack("setting loop_mode")
1157        return modes
1158
1159    def _apply_loop_duration(self, values):
1160        durations = self._normalize_loop_durations(values)
1161        samples = self._durations_to_samples(durations, self._sampling_rate)
1162        self._send_loop_duration_samples(samples)
1163        return durations
1164
1165    def _normalize_loop_durations(self, values):
1166        """Check new loop durations for all channels, and return them as the list holds them, in seconds."""
1167        durations = []
1168        for value in values:
1169            if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
1170                raise PulsePalError(
1171                    f"loop_duration must be in seconds. Received {value!r}."
1172                )
1173            duration = float(value)
1174            if not math.isfinite(duration) or duration < 0:
1175                raise PulsePalError(
1176                    "loop_duration must be 0 (loop until stopped) or a "
1177                    f"positive number of seconds. Received {value!r}."
1178                )
1179            durations.append(duration)
1180        return durations
1181
1182    def _send_loop_duration_samples(self, samples):
1183        self._write_command(self._OP_SET_LOOP_DURATION,
1184                            struct.pack("<4I", *samples))
1185        self._read_ack("setting loop_duration")
1186
1187    def _durations_to_samples(self, durations, rate_hz):
1188        """Convert loop durations in seconds to samples at a sampling rate."""
1189        actual_rate = self._actual_rate(rate_hz)
1190        samples = []
1191        for duration in durations:
1192            n = round(duration * actual_rate)
1193            if duration > 0:
1194                n = max(n, 1)  # 0 samples would mean "loop until stopped"
1195            if n > self._UINT32_MAX:
1196                raise PulsePalError(
1197                    f"A loop_duration of {duration} s is too long at "
1198                    f"{rate_hz} Hz. The longest is "
1199                    f"{self._UINT32_MAX / actual_rate:.0f} s."
1200                )
1201            samples.append(n)
1202        return samples
1203
1204    def _apply_trigger_mode(self, values):
1205        names = [to_name(value, TRIGGER_MODES, "trigger mode") for value in values]
1206        self._write_command(
1207            self._OP_SET_TRIGGER_MODE,
1208            bytes(_TRIGGER_MODE_CODES[name] for name in names),
1209        )
1210        self._read_ack("setting trigger_mode")
1211        return names
1212
1213    # One op programs both trigger channels' links, so each list is sent
1214    # with the other's current values
1215    def _apply_trigger_channel1_links(self, values):
1216        links1 = [to_bool(v, "link_trigger_channel1") for v in values]
1217        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
1218        return links1
1219
1220    def _apply_trigger_channel2_links(self, values):
1221        links2 = [to_bool(v, "link_trigger_channel2") for v in values]
1222        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
1223        return links2
1224
1225    def _set_trigger_links(self, links1, links2):
1226        """Program both trigger channels' links with one command."""
1227        self._send_trigger_links(links1, links2)
1228        self._link_trigger_channel1._store(links1)
1229        self._link_trigger_channel2._store(links2)
1230
1231    def _send_trigger_links(self, links1, links2):
1232        self._write_command(self._OP_SET_TRIGGER_LINKS,
1233                            bytes([*map(int, links1), *map(int, links2)]))
1234        self._read_ack("setting the trigger channel links")
1235
1236    def _actual_rate(self, rate_hz):
1237        # The sample period is the nearest whole number of clock cycles, computed in integers as the
1238        # firmware's setSamplingRate() does. A rate halfway between two (e.g. 3072 Hz: 7812.5 cycles)
1239        # rounds up there, where round() would round to even.
1240        clock = self.info.sample_clock_hz
1241        return clock / ((clock + rate_hz // 2) // rate_hz)
1242
1243    def _normalize_sampling_rate(self, rate):
1244        """Check a sampling rate, and return it as a whole number of Hz."""
1245        try:
1246            rate_hz = int(rate)
1247            is_whole = rate_hz == rate and not isinstance(rate, (bool, str))
1248        except (TypeError, ValueError, OverflowError):
1249            is_whole = False
1250        if not is_whole or not 1 <= rate_hz <= self.info.max_sampling_rate:
1251            raise PulsePalError(
1252                "sampling_rate must be a whole number of Hz from 1 to "
1253                f"{self.info.max_sampling_rate}. Received {rate!r}."
1254            )
1255        return rate_hz
1256
1257    def _check_output_range(self, range_name, trigger_modes):
1258        """Check that the output range can be set, with these trigger modes, and return its canonical name."""
1259        name = self._range_name(range_name)
1260        low, high = OUTPUT_RANGES[name]
1261        for number, waveform in enumerate(self._waveforms):
1262            if waveform is not None and (
1263                    waveform.min() < low or waveform.max() > high):
1264                raise PulsePalError(
1265                    f"Waveform {number} spans "
1266                    f"{waveform.min():g} V to {waveform.max():g} V, which "
1267                    f"does not fit the {name} range. Load a new waveform "
1268                    "first, or choose a wider range."
1269                )
1270        if name != self._output_range and "Param Sync" in trigger_modes:
1271            raise PulsePalError(
1272                "output_range cannot change while a trigger channel is in param sync mode: the waveforms are "
1273                "loaded again for the new range, and in param sync mode they would wait for a TTL edge. Set "
1274                "trigger_mode to another mode first."
1275            )
1276        return name
1277
1278    def _range_name(self, range_name):
1279        """Return the canonical name of an output range."""
1280        if isinstance(range_name, str):
1281            for name in OUTPUT_RANGES:
1282                if name.lower() == range_name.replace(" ", "").lower():
1283                    return name
1284        raise PulsePalError(
1285            f"Unknown output range: {range_name!r}. Valid ranges are "
1286            f"{', '.join(OUTPUT_RANGES)}."
1287        )
1288
1289    def _volts_to_codes(self, volts):
1290        """Convert voltages to DAC codes in the current output range."""
1291        low, high = OUTPUT_RANGES[self._output_range]
1292        volts = np.asarray(volts, dtype=float)
1293        if not np.all(np.isfinite(volts)):
1294            raise PulsePalError("Voltages must be finite numbers.")
1295        if volts.min() < low or volts.max() > high:
1296            raise PulsePalError(
1297                f"Voltages must be within the output range, {low:g} V to "
1298                f"{high:g} V. Received {volts.min():g} V to "
1299                f"{volts.max():g} V. Change output_range to use a wider "
1300                "range."
1301            )
1302        codes = np.round((volts - low) / (high - low) * self._DAC_BITMAX)
1303        return codes.astype("<u2")
1304
1305    def _waveform_number(self, number):
1306        if (
1307            not isinstance(number, numbers.Integral)
1308            or isinstance(number, (bool, np.bool_))
1309            or not 1 <= number <= self.info.n_waveforms
1310        ):
1311            raise PulsePalError(
1312                f"Waveforms are numbered 1-{self.info.n_waveforms}. Received {number!r}."
1313            )
1314        return int(number)
1315
1316    def _write_command(self, op_code, data=b""):
1317        """Send one command, with its framing byte, in a single write.
1318
1319        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
1320        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it would put every
1321        reply after it out of step. So it is discarded first (_discard_lost_reply()), and this command's reply, if it
1322        has one, is pending until _read_raw() has read it, as in PulsePalDevice.
1323        """
1324        if self._reply_pending is not None:
1325            self._discard_lost_reply()
1326        if op_code not in _OPS_WITHOUT_TRACKED_REPLY:
1327            self._reply_pending = op_code  # Before the write: a Ctrl-C can come as soon as it returns
1328        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1329        bytes_written = self.port.write(message)
1330        if bytes_written != len(message):
1331            raise PulsePalError(
1332                f"Wrote {bytes_written} byte(s), expected to write "
1333                f"{len(message)} byte(s)."
1334            )
1335
1336    def _discard_lost_reply(self):
1337        """Discard what arrives of a reply that was not read in full (see _write_command()), and bring the device
1338        back in step with this object.
1339
1340        The command may have changed a setting, unknown to this object: an assignment interrupted after its write,
1341        for one, which the device took and this object did not. Wave Pal cannot report its settings, so the setting
1342        such a command carried is sent again as this object holds it. A change of output range unloads the device's
1343        waveforms, so after one they are loaded again too. A waveform whose load was lost is already left out of
1344        `waveforms`.
1345        """
1346        op = self._reply_pending
1347        self._reply_pending = None
1348        n_late = len(_common.read_until_quiet(self.port))
1349        send_again = op in self._SETTING_OPS
1350        _common.warn_lost_reply(f"op {chr(op)!r}", n_late,
1351                                ", and the setting it carried was sent again as this object holds it" if send_again
1352                                else "")
1353        if send_again:
1354            try:
1355                self._send_setting_again(op)
1356            except PulsePalError as exc:
1357                raise PulsePalError(f"After the reply to an earlier command was lost: {exc}") from exc
1358
1359    def _send_setting_again(self, op):
1360        """Send the setting that op carries, as this object holds it (see _discard_lost_reply())."""
1361        if op == self._OP_SET_SAMPLING_RATE:
1362            self._write_command(op, struct.pack("<I", self._sampling_rate))
1363            self._read_ack("setting sampling_rate")
1364        elif op == self._OP_SET_OUTPUT_RANGE:
1365            self._write_command(op, bytes([list(OUTPUT_RANGES).index(self._output_range)]))
1366            self._read_ack("setting output_range")
1367            waveforms = self._waveforms
1368            self._waveforms = [None] * len(waveforms)
1369            for number, waveform in enumerate(waveforms):
1370                if waveform is not None:
1371                    self.load_waveform(number, waveform)
1372        elif op == self._OP_SET_CURRENT_WAVEFORMS:
1373            self._write_command(op, bytes(self._current_waveform[1:]))
1374            self._read_ack("setting current_waveform")
1375        elif op == self._OP_SET_LOOP_MODE:
1376            self._write_command(op, bytes(map(int, self._loop_mode[1:])))
1377            self._read_ack("setting loop_mode")
1378        elif op == self._OP_SET_LOOP_DURATION:
1379            self._send_loop_duration_samples(self._durations_to_samples(self._loop_duration[1:], self._sampling_rate))
1380        elif op == self._OP_SET_TRIGGER_MODE:
1381            self._write_command(op, bytes(_TRIGGER_MODE_CODES[name] for name in self._trigger_mode[1:]))
1382            self._read_ack("setting trigger_mode")
1383        else:  # Trigger links
1384            self._send_trigger_links(self._link_trigger_channel1[1:], self._link_trigger_channel2[1:])
1385
1386    def _read_raw(self, n_bytes):
1387        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
1388        longer pending (see _write_command()). Only connecting reads a reply in more than one call."""
1389        message = self.port.read(n_bytes)
1390        if len(message) < n_bytes:
1391            raise PulsePalError(
1392                f"Wave Pal sent {len(message)} of the {n_bytes} byte(s) of its reply within "
1393                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
1394            )
1395        self._reply_pending = None
1396        return message
1397
1398    def _read_ack(self, context):
1399        """Read a one-byte confirmation: 1 if the device executed the
1400        command, 0 if it rejected it."""
1401        try:
1402            reply = self._read_raw(1)[0]
1403        except PulsePalError as exc:
1404            raise PulsePalError(
1405                f"Wave Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
1406                f"{_common.NO_REPLY_HINT}"
1407            ) from exc
1408        if reply != 1:
1409            raise PulsePalError(
1410                f"Wave Pal rejected {context}. A value was out of range, "
1411                "or a waveform could not be written to the microSD card."
1412            )

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

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

from pulsepal import WavePalDevice

W = WavePalDevice("COM3")
W.load_waveform(1, [0, 1, 2, 3, 4, 5, 0], 1)   # waveform 1, played by channel 1
W.trigger(1)
W.close()

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

Closing the connection stops playback, as it does on Pulse Pal, and puts the device's own name back on its screen. The device keeps its settings and waveforms, so TTL triggers still play them.

WavePalDevice(port_name=None, *, serial_number=None, timeout=10)
332    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
333        """Open a connection to a Wave Pal.
334
335        Args:
336            port_name: USB serial port of the device, such as `COM3` on
337                Windows or `/dev/ttyACM0` on Linux.
338            serial_number: Instead of `port_name`, the device's USB serial
339                number (`DeviceInfo.serial_number`), which finds its port
340                wherever it is plugged in.
341            timeout: Serial read timeout, in seconds. Loading a long
342                waveform onto a slow microSD card can take a few seconds.
343
344        Firmware newer than this module knows is used with a warning: new
345        firmware only adds commands. Update the package to use what is new.
346
347        Raises:
348            PulsePalError: If the device does not reply to the handshake, or
349                runs other firmware.
350            serial.SerialException: If the serial port cannot be opened,
351                e.g. because another program has it open (on Linux and
352                macOS, another session of these classes, which lock it).
353        """
354        self._closed = True
355        self.info = DeviceInfo()
356        self._sampling_rate = None
357        self._output_range = None
358        self._waveforms = []  # Indexed by waveform number, once the device has said how many it stores
359        self._current_waveform = ChannelSettings(
360            "current_waveform", [0] * 4, self, "_apply_current_waveform")
361        self._loop_mode = ChannelSettings(
362            "loop_mode", [False] * 4, self, "_apply_loop_mode")
363        self._loop_duration = ChannelSettings(
364            "loop_duration", [0.0] * 4, self, "_apply_loop_duration")
365        self._trigger_mode = ChannelSettings(
366            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
367        self._link_trigger_channel1 = ChannelSettings(
368            "link_trigger_channel1", [False] * 4, self,
369            "_apply_trigger_channel1_links")
370        self._link_trigger_channel2 = ChannelSettings(
371            "link_trigger_channel2", [False] * 4, self,
372            "_apply_trigger_channel2_links")
373
374        port_name = _common.port_name_from(port_name, serial_number, "WavePalDevice")
375        self.port = serial.Serial(
376            port_name,
377            self._BAUD_RATE,
378            timeout=timeout,
379            rtscts=True,
380            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
381            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
382            # it, such as another of these classes, gets an error instead
383            exclusive=True,
384        )
385        self._closed = False
386        try:
387            # Discard anything left in the buffer by an earlier session
388            self.port.reset_input_buffer()
389            self._handshake()
390            self._read_hardware_info()
391            self._waveforms = [None] * (self.info.n_waveforms + 1)
392            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
393            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
394            self.stop()
395            self.set_default_params()
396        except BaseException:
397            # Op 81 means something else to other devices, so it is sent
398            # only once the device has identified itself as a Wave Pal
399            self._close(send_disconnect=self.info.firmware_version is not None)
400            raise

Open a connection to a Wave Pal.

Arguments:
  • port_name: USB serial port of the device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • serial_number: Instead of port_name, the device's USB serial number (DeviceInfo.serial_number), which finds its port wherever it is plugged in.
  • timeout: Serial read timeout, in seconds. Loading a long waveform onto a slow microSD card can take a few seconds.

Firmware newer than this module knows is used with a warning: new firmware only adds commands. Update the package to use what is new.

Raises:
  • PulsePalError: If the device does not reply to the handshake, or runs other firmware.
  • 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.

Properties of the connected device. See DeviceInfo.

@staticmethod
def serialportlist(ports_to_list='available'):
402    @staticmethod
403    def serialportlist(ports_to_list="available"):
404        """Return the names of the USB serial ports on this computer.
405
406        Called on the class, without connecting to a device, to find the
407        port name to pass to `WavePalDevice`.
408
409        Args:
410            ports_to_list: `available` to list only the ports that are
411                not already in use, or `all` to list every USB serial
412                port. Not case sensitive.
413
414        Returns:
415            Sorted list of port names, such as `["COM3", "COM7"]`.
416
417        Raises:
418            PulsePalError: If `ports_to_list` is not `available` or `all`.
419        """
420        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 WavePalDevice.

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

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

Raises:
  • PulsePalError: If ports_to_list is not available or all.
def set_default_params(self):
426    def set_default_params(self):
427        """Program the default settings on the device.
428
429        The defaults are both trigger channels in normal mode, a 10 kHz
430        sampling rate, the -10 V to 10 V output range, loop mode off with
431        loop durations of 0, no output channel linked to a trigger channel
432        (so a TTL starts nothing until a script links it), and no current
433        waveform on any channel. They match the settings the device starts with. The
434        trigger modes are sent first: a trigger channel leaving param sync
435        mode makes what was stored for its next edge current, and the
436        settings after it then apply at once.
437
438        Loaded waveforms are kept, and loaded again if the output range
439        changes (see `WavePalDevice.output_range`).
440
441        Raises:
442            PulsePalError: If a loaded waveform does not fit the default
443                output range, -10 V to 10 V.
444        """
445        self.trigger_mode = ["Normal"] * 2
446        self.sampling_rate = 10000
447        self.output_range = "-10V:10V"
448        self.loop_mode = [False] * 4
449        self.loop_duration = [0] * 4
450        self._set_trigger_links([False] * 4, [False] * 4)
451        self.current_waveform = [0] * 4

Program the default settings on the device.

The defaults are both trigger channels in normal mode, a 10 kHz sampling rate, the -10 V to 10 V output range, loop mode off with loop durations of 0, no output channel linked to a trigger channel (so a TTL starts nothing until a script links it), and no current waveform on any channel. They match the settings the device starts with. The trigger modes are sent first: a trigger channel leaving param sync mode makes what was stored for its next edge current, and the settings after it then apply at once.

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

Raises:
  • PulsePalError: If a loaded waveform does not fit the default output range, -10 V to 10 V.
sampling_rate
453    @property
454    def sampling_rate(self):
455        """Sampling rate of all output channels, in Hz.
456
457        A whole number from 1 to `DeviceInfo.max_sampling_rate`. It can be
458        changed during playback. The rate played can differ slightly from
459        the rate set: see `WavePalDevice.actual_sampling_rate`.
460        """
461        return self._sampling_rate

Sampling rate of all output channels, in Hz.

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

actual_sampling_rate
479    @property
480    def actual_sampling_rate(self):
481        """The sampling rate the device plays, in Hz.
482
483        The device divides `DeviceInfo.sample_clock_hz` (24 MHz) by a
484        whole number, so the rate played is the nearest one of those to
485        `WavePalDevice.sampling_rate`. For example, 44100 Hz plays at
486        44117.6 Hz. Rates that divide 24 MHz exactly, such as 10 kHz,
487        25 kHz or 100 kHz, play exactly.
488        """
489        return self._actual_rate(self._sampling_rate)

The sampling rate the device plays, in Hz.

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

output_range
491    @property
492    def output_range(self):
493        """Voltage range of all output channels.
494
495        One of `DeviceInfo.output_ranges`: `"0V:5V"`, `"0V:10V"`,
496        `"-5V:5V"` or `"-10V:10V"`. The smallest range that fits the
497        waveforms gives the finest voltage steps.
498
499        Changing the range stops playback, sets all outputs to 0 V, and
500        loads the waveforms again, encoded for the new range. It raises
501        an error, and changes nothing, if a loaded waveform does not fit
502        the new range, or if a trigger channel is in param sync mode,
503        where the loads would wait for a TTL edge.
504        """
505        return self._output_range

Voltage range of all output channels.

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

Changing the range stops playback, sets all outputs to 0 V, and loads the waveforms again, encoded for the new range. It raises an error, and changes nothing, if a loaded waveform does not fit the new range, or if a trigger channel is in param sync mode, where the loads would wait for a TTL edge.

current_waveform
525    @property
526    def current_waveform(self):
527        """The waveform each output channel plays, by waveform number.
528
529        Indexed by channel number (see "Channel settings" above). Each
530        value is the number of a waveform loaded with
531        `WavePalDevice.load_waveform`, 1 to `DeviceInfo.n_waveforms`, or
532        `0` for none, the default: a channel with no waveform ignores
533        triggers. Several channels can play the same waveform.
534
535        A channel that is playing finishes its waveform: the new one plays
536        from the channel's next start. In param sync mode, a change waits
537        for the next rising edge on the param sync trigger channel (see
538        `WavePalDevice.trigger_mode`).
539
540        ```python
541        W.current_waveform[1] = 2            # channel 1 plays waveform 2
542        W.current_waveform = [1, 1, 1, 1]    # all four play waveform 1
543        ```
544        """
545        return self._current_waveform

The waveform each output channel plays, by waveform number.

Indexed by channel number (see "Channel settings" above). Each value is the number of a waveform loaded with WavePalDevice.load_waveform, 1 to DeviceInfo.n_waveforms, or 0 for none, the default: a channel with no waveform ignores triggers. Several channels can play the same waveform.

A channel that is playing finishes its waveform: the new one plays from the channel's next start. In param sync mode, a change waits for the next rising edge on the param sync trigger channel (see WavePalDevice.trigger_mode).

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

Whether each output channel loops its waveform.

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

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

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

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

trigger_mode
583    @property
584    def trigger_mode(self):
585        """How each trigger channel acts on the output channels linked to it.
586
587        Indexed by trigger channel number, 1 or 2 (index 0 is unused), as
588        in Pulse Pal. A TTL edge on a trigger channel acts on the output
589        channels linked to it (`WavePalDevice.link_trigger_channel1`,
590        `WavePalDevice.link_trigger_channel2`):
591
592        - `"Normal"`: a rising edge starts the linked channels' waveforms.
593          Channels that are playing ignore it.
594        - `"Toggle"`: a rising edge starts the linked channels, or stops
595          those that are playing.
596        - `"Gated"`: a rising edge starts the linked channels, and a
597          falling edge stops them, unless the other trigger channel is also
598          gated, linked to them, and still high. With loop mode on and a
599          loop duration of 0, a waveform plays for exactly as long as the
600          TTL is high.
601        - `"Param Sync"`: a rising edge starts and stops nothing (the
602          channel's links are ignored). It makes the waveforms loaded,
603          and the `WavePalDevice.current_waveform` set, since the previous
604          edge current. This is how the next trial's waveforms are loaded
605          during the current trial, and played from the instant it starts.
606        - `"Master"`: a rising edge starts the linked channels, and
607          restarts those that are playing from their first sample.
608
609        The first four are Pulse Pal's trigger modes. Names are not case
610        sensitive.
611
612        While a trigger channel is in param sync mode,
613        `WavePalDevice.load_waveform` loads into a second memory block
614        that the device keeps for each waveform. Channels go on playing
615        the waveform it replaces, and triggers still start it, until the
616        edge. A channel that is playing at the edge finishes what it plays,
617        and plays the new waveform from its next start. A later load of the
618        same waveform before the edge replaces the earlier one, and stops
619        any channel still playing the waveform from before the previous
620        edge, since the device reuses that memory block. Changes to
621        `WavePalDevice.current_waveform` wait for the edge in the same
622        way. Other settings apply at once.
623
624        When neither trigger channel is in param sync mode any more, what
625        was waiting for an edge becomes current at once, so
626        `WavePalDevice.waveforms` and `WavePalDevice.current_waveform`
627        describe the device again. (Pulse Pal and Synth Pal discard their
628        stored parameters and settings then; Pulse Pal makes its stored
629        custom trains current, as this class does its waveforms.)
630        `WavePalDevice.output_range` cannot change
631        in param sync mode. To make the waveforms current on the same edge
632        that starts the channels, wire the TTL to the other trigger channel
633        as well: the param sync edge is handled first.
634
635        ```python
636        W.trigger_mode[2] = "Param Sync"      # sent at once
637        W.load_waveform(1, next_trial_wave)   # waits for trigger channel 2's next rising edge
638        ```
639        """
640        return self._trigger_mode

How each trigger channel acts on the output channels linked to it.

Indexed by trigger channel number, 1 or 2 (index 0 is unused), as in Pulse Pal. A TTL edge on a trigger channel acts on the output channels linked to it (WavePalDevice.link_trigger_channel1, WavePalDevice.link_trigger_channel2):

  • "Normal": a rising edge starts the linked channels' waveforms. Channels that are playing ignore it.
  • "Toggle": a rising edge starts the linked channels, or stops those that are playing.
  • "Gated": a rising edge starts the linked channels, and a falling edge stops them, unless the other trigger channel is also gated, linked to them, and still high. With loop mode on and a loop duration of 0, a waveform plays for exactly as long as the TTL is high.
  • "Param Sync": a rising edge starts and stops nothing (the channel's links are ignored). It makes the waveforms loaded, and the WavePalDevice.current_waveform set, since the previous edge current. This is how the next trial's waveforms are loaded during the current trial, and played from the instant it starts.
  • "Master": a rising edge starts the linked channels, and restarts those that are playing from their first sample.

The first four are Pulse Pal's trigger modes. Names are not case sensitive.

While a trigger channel is in param sync mode, WavePalDevice.load_waveform loads into a second memory block that the device keeps for each waveform. Channels go on playing the waveform it replaces, and triggers still start it, until the edge. A channel that is playing at the edge finishes what it plays, and plays the new waveform from its next start. A later load of the same waveform before the edge replaces the earlier one, and stops any channel still playing the waveform from before the previous edge, since the device reuses that memory block. Changes to WavePalDevice.current_waveform wait for the edge in the same way. Other settings apply at once.

When neither trigger channel is in param sync mode any more, what was waiting for an edge becomes current at once, so WavePalDevice.waveforms and WavePalDevice.current_waveform describe the device again. (Pulse Pal and Synth Pal discard their stored parameters and settings then; Pulse Pal makes its stored custom trains current, as this class does its waveforms.) WavePalDevice.output_range cannot change in param sync mode. To make the waveforms current on the same edge that starts the channels, wire the TTL to the other trigger channel as well: the param sync edge is handled first.

W.trigger_mode[2] = "Param Sync"      # sent at once
W.load_waveform(1, next_trial_wave)   # waits for trigger channel 2's next rising edge
def set_screen_saver(self, enabled, timeout=1800):
672    def set_screen_saver(self, enabled, timeout=1800):
673        """Switch the device's screen saver on or off, and set its timeout.
674
675        With the screen saver on, the device dims its screen once it has
676        been left alone for `timeout` seconds: no command from a computer,
677        no rising edge on a trigger channel, and no joystick click or
678        push. The next of these brings the screen back. The device keeps
679        both settings in its EEPROM, shared with Pulse Pal and Synth Pal
680        firmware, and saves them once no channel is playing. A new device
681        starts with the screen saver on and 1800 s.
682
683        Args:
684            enabled: `True` to switch the screen saver on, `False` to
685                switch it off.
686            timeout: Seconds without activity before the screen dims, 1 to
687                65535. It is sent with every call, so leaving it out sets
688                1800 s.
689
690        Raises:
691            PulsePalError: If a value is out of range.
692        """
693        state = to_bool(enabled, "enabled")
694        if isinstance(timeout, (bool, np.bool_)) \
695                or not isinstance(timeout, numbers.Integral) \
696                or not 1 <= timeout <= 65535:
697            raise PulsePalError(
698                "timeout must be a whole number of seconds from 1 to 65535. "
699                f"Received {timeout!r}."
700            )
701        self._write_command(self._OP_SET_SCREEN_SAVER,
702                            struct.pack("<BH", int(state), int(timeout)))
703        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 a computer, no rising edge on a trigger channel, and no joystick click or push. The next of these brings the screen back. The device keeps both settings in its EEPROM, shared with Pulse Pal and Synth Pal firmware, and saves them once no channel is playing. A new device starts with the screen saver on and 1800 s.

Arguments:
  • enabled: True to switch the screen saver on, False to switch it off.
  • timeout: Seconds without activity before the screen dims, 1 to
    1. It is sent with every call, so leaving it out sets 1800 s.
Raises:
  • PulsePalError: If a value is out of range.
def export_params(self):
705    def export_params(self):
706        """Return every setting, as a dict of plain values and lists.
707
708        Keyed by setting name: `"sampling_rate"` and `"output_range"`, then
709        the channel settings, each with one value per channel and no
710        unused index 0. It holds only numbers, booleans and names, so it
711        can be saved with `json` and logged with your data. The waveforms
712        are not part of it: `WavePalDevice.waveforms` holds them.
713
714        ```python
715        import json
716        with open("session_settings.json", "w") as f:
717            json.dump(W.export_params(), f)
718        ```
719        """
720        params = {"sampling_rate": self._sampling_rate, "output_range": self._output_range}
721        for name in _CHANNEL_SETTINGS:
722            params[name] = list(getattr(self, name))[1:]
723        return params

Return every setting, as a dict of plain values and lists.

Keyed by setting name: "sampling_rate" and "output_range", then the channel settings, each with one value per channel and no unused index 0. It holds only numbers, booleans and names, so it can be saved with json and logged with your data. The waveforms are not part of it: WavePalDevice.waveforms holds them.

import json
with open("session_settings.json", "w") as f:
    json.dump(W.export_params(), f)
def import_params(self, params):
725    def import_params(self, params):
726        """Program the device with settings exported by `WavePalDevice.export_params`.
727
728        All of them are checked first, and nothing is sent if one is
729        invalid. They are then sent in an order that lets each take
730        effect: a trigger channel leaving param sync mode first, then the
731        sampling rate, the output range and the channel settings, and a
732        trigger channel entering param sync mode last. So
733        `WavePalDevice.current_waveform` applies at once, unless a trigger
734        channel was in param sync mode and stays in it: then it waits for
735        the next edge, as an assignment would. Settings missing from
736        `params` keep their values. A name that is not a setting is
737        skipped with a warning, so that settings exported by a newer
738        version of this package, which may have more, can still be
739        imported.
740
741        The waveforms are not settings: load them with
742        `WavePalDevice.load_waveform` first, since a current waveform must
743        be a loaded one.
744
745        ```python
746        import json
747        with open("session_settings.json") as f:
748            W.import_params(json.load(f))
749        ```
750
751        Args:
752            params: A dict of setting name to its value: a number for
753                `"sampling_rate"`, a name for `"output_range"`, and one
754                value per channel (a list, tuple or NumPy array) for the
755                others, as `export_params` returns.
756
757        Raises:
758            PulsePalError: If a value is invalid (nothing is sent), or the
759                device rejects a setting.
760        """
761        names = {"sampling_rate", "output_range", *_CHANNEL_SETTINGS}
762        unknown = [name for name in params if name not in names]
763        if unknown:
764            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
765                          "WavePalDevice does not have.", stacklevel=2)
766        new = {}
767        for name in _CHANNEL_SETTINGS:
768            if name in params:
769                n = 2 if name == "trigger_mode" else self.info.n_channels
770                new[name] = _common.one_value_per_channel(name, params[name], n)
771        if "current_waveform" in new:
772            new["current_waveform"] = self._normalize_current_waveform(new["current_waveform"])
773        if "loop_mode" in new:
774            new["loop_mode"] = [to_bool(value, "loop_mode") for value in new["loop_mode"]]
775        if "loop_duration" in new:
776            new["loop_duration"] = self._normalize_loop_durations(new["loop_duration"])
777        for name in ("link_trigger_channel1", "link_trigger_channel2"):
778            if name in new:
779                new[name] = [to_bool(value, name) for value in new[name]]
780        modes = list(self._trigger_mode)[1:]
781        if "trigger_mode" in new:
782            modes = [to_name(value, TRIGGER_MODES, "trigger mode") for value in new["trigger_mode"]]
783        # The trigger modes the other settings are sent in: those of channels leaving param sync mode are sent
784        # first, those of channels entering it last
785        first_modes = [new_mode if (old == "Param Sync" and new_mode != "Param Sync") else old
786                       for old, new_mode in zip(self._trigger_mode[1:], modes)]
787        rate = self._sampling_rate
788        if "sampling_rate" in params:
789            rate = self._normalize_sampling_rate(params["sampling_rate"])
790        range_name = self._output_range
791        if "output_range" in params:
792            range_name = self._check_output_range(params["output_range"], first_modes)
793        self._durations_to_samples(new.get("loop_duration", self._loop_duration[1:]), rate)  # They fit at the rate
794
795        if first_modes != list(self._trigger_mode)[1:]:
796            self.trigger_mode = first_modes
797        if rate != self._sampling_rate:
798            self.sampling_rate = rate
799        if range_name != self._output_range:
800            self.output_range = range_name
801        for name in ("loop_mode", "loop_duration"):
802            if name in new:
803                setattr(self, name, new[name])
804        if "link_trigger_channel1" in new or "link_trigger_channel2" in new:
805            self._set_trigger_links(new.get("link_trigger_channel1", self._link_trigger_channel1[1:]),
806                                    new.get("link_trigger_channel2", self._link_trigger_channel2[1:]))
807        if "current_waveform" in new:
808            self.current_waveform = new["current_waveform"]
809        if modes != first_modes:
810            self.trigger_mode = modes

Program the device with settings exported by WavePalDevice.export_params.

All of them are checked first, and nothing is sent if one is invalid. They are then sent in an order that lets each take effect: a trigger channel leaving param sync mode first, then the sampling rate, the output range and the channel settings, and a trigger channel entering param sync mode last. So WavePalDevice.current_waveform applies at once, unless a trigger channel was in param sync mode and stays in it: then it waits for the next edge, as an assignment would. Settings missing from params keep their values. A name that is not a setting is skipped with a warning, so that settings exported by a newer version of this package, which may have more, can still be imported.

The waveforms are not settings: load them with WavePalDevice.load_waveform first, since a current waveform must be a loaded one.

import json
with open("session_settings.json") as f:
    W.import_params(json.load(f))
Arguments:
  • params: A dict of setting name to its value: a number for "sampling_rate", a name for "output_range", and one value per channel (a list, tuple or NumPy array) for the others, as export_params returns.
Raises:
  • PulsePalError: If a value is invalid (nothing is sent), or the device rejects a setting.
waveforms
812    @property
813    def waveforms(self):
814        """The waveforms loaded with `WavePalDevice.load_waveform`.
815
816        A list indexed by waveform number, 1 to `DeviceInfo.n_waveforms`,
817        with index 0 unused. Each element is a read-only NumPy array of
818        voltages, or `None` if this object has not loaded that waveform. In
819        param sync mode, it holds the waveforms loaded since the last edge
820        before the device plays them. `WavePalDevice.status` shows what
821        the device itself holds.
822        """
823        return list(self._waveforms)

The waveforms loaded with WavePalDevice.load_waveform.

A list indexed by waveform number, 1 to DeviceInfo.n_waveforms, with index 0 unused. Each element is a read-only NumPy array of voltages, or None if this object has not loaded that waveform. In param sync mode, it holds the waveforms loaded since the last edge before the device plays them. WavePalDevice.status shows what the device itself holds.

def load_waveform(self, waveform_number, waveform, channels=None):
829    def load_waveform(self, waveform_number, waveform, channels=None):
830        """Load one of the device's waveforms, and choose the channels that play it.
831
832        The samples are written to the device's microSD card, and the
833        first `DeviceInfo.buffer_samples` of them are also kept in its RAM.
834        Outside param sync mode, loading stops the channels playing that
835        waveform, and the new one plays from their next start. Other
836        channels keep playing. In param sync mode, the new waveform waits
837        for the next rising edge on the param sync trigger channel, and
838        channels go on playing the old one until then (see
839        `WavePalDevice.trigger_mode`).
840
841        ```python
842        t = np.arange(10000) / W.sampling_rate
843        W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t), [1, 3])
844        ```
845
846        Args:
847            waveform_number: The waveform to load, 1 to
848                `DeviceInfo.n_waveforms`.
849            waveform: Voltages of the samples, played at
850                `WavePalDevice.sampling_rate`. A list, tuple or NumPy
851                array of 1 to `DeviceInfo.max_samples` values, all within
852                `WavePalDevice.output_range`.
853            channels: Output channels that play this waveform, from their
854                next start: one channel number 1-4, or several as a list,
855                tuple or NumPy array. It sets their
856                `WavePalDevice.current_waveform`, and leaves the other
857                channels' alone, so channels already playing this waveform
858                play the new one. `None`, the default, changes no channel.
859
860        Raises:
861            PulsePalError: If an argument is invalid, or the device could
862                not store the waveform. The waveform is then left out of
863                `WavePalDevice.waveforms`: outside param sync mode the
864                device has unloaded it, and in param sync mode it plays the
865                version made current at the last edge.
866        """
867        number = self._waveform_number(waveform_number)
868        channel_list = None if channels is None else _common.channel_numbers(channels)
869        samples = np.array(waveform, dtype=float).ravel()
870        if not 1 <= samples.size <= self.info.max_samples:
871            raise PulsePalError(
872                f"A waveform must have 1 to {self.info.max_samples} samples. "
873                f"Received {samples.size}."
874            )
875        codes = self._volts_to_codes(samples)
876        self._waveforms[number] = None  # Until the device has stored it. Outside param sync mode, it unloads it first.
877        self._write_command(
878            self._OP_LOAD_WAVEFORM,
879            struct.pack("<BI", number, samples.size) + codes.tobytes(),
880        )
881        self._read_ack("load_waveform()")
882        samples.flags.writeable = False
883        self._waveforms[number] = samples
884        if channel_list:
885            values = list(self._current_waveform[1:])
886            for channel in channel_list:
887                values[channel - 1] = number
888            self._current_waveform._assign(values)

Load one of the device's waveforms, and choose the channels that play it.

The samples are written to the device's microSD card, and the first DeviceInfo.buffer_samples of them are also kept in its RAM. Outside param sync mode, loading stops the channels playing that waveform, and the new one plays from their next start. Other channels keep playing. In param sync mode, the new waveform waits for the next rising edge on the param sync trigger channel, and channels go on playing the old one until then (see WavePalDevice.trigger_mode).

t = np.arange(10000) / W.sampling_rate
W.load_waveform(2, 3 * np.sin(2 * np.pi * 100 * t), [1, 3])
Arguments:
  • waveform_number: The waveform to load, 1 to DeviceInfo.n_waveforms.
  • waveform: Voltages of the samples, played at WavePalDevice.sampling_rate. A list, tuple or NumPy array of 1 to DeviceInfo.max_samples values, all within WavePalDevice.output_range.
  • channels: Output channels that play this waveform, from their next start: one channel number 1-4, or several as a list, tuple or NumPy array. It sets their WavePalDevice.current_waveform, and leaves the other channels' alone, so channels already playing this waveform play the new one. None, the default, changes no channel.
Raises:
  • PulsePalError: If an argument is invalid, or the device could not store the waveform. The waveform is then left out of WavePalDevice.waveforms: outside param sync mode the device has unloaded it, and in param sync mode it plays the version made current at the last edge.
def trigger(self, channels):
890    def trigger(self, channels):
891        """Trigger output channels in software.
892
893        Idle channels start their current waveforms from the first
894        sample, together. Channels that are playing ignore it, as Pulse
895        Pal's channels ignore a soft trigger while they play a pulse train.
896
897        ```python
898        W.trigger(1)
899        W.trigger([2, 4])     # a list, tuple or NumPy array
900        ```
901
902        Args:
903            channels: Output channel number 1-4, or several as a list,
904                tuple or NumPy array.
905
906        Raises:
907            PulsePalError: If a channel has no waveform to play: its
908                `WavePalDevice.current_waveform` is 0, or a waveform this
909                object has not loaded. Nothing is sent.
910        """
911        channel_list = _common.channel_numbers(channels)
912        for channel in channel_list:
913            number = self._current_waveform[channel]
914            if number == 0:
915                raise PulsePalError(
916                    f"Channel {channel} has no waveform to play. Set current_waveform[{channel}] to a loaded "
917                    f"waveform's number, or load one with load_waveform(number, waveform, {channel})."
918                )
919            if self._waveforms[number] is None:
920                raise PulsePalError(
921                    f"Channel {channel} plays waveform {number}, which is not loaded. Load it with "
922                    f"load_waveform({number}, waveform)."
923                )
924        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channel_list)]))

Trigger output channels in software.

Idle channels start their current waveforms from the first sample, together. Channels that are playing ignore it, as Pulse Pal's channels ignore a soft trigger while they play a pulse train.

W.trigger(1)
W.trigger([2, 4])     # 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 has no waveform to play: its WavePalDevice.current_waveform is 0, or a waveform this object has not loaded. Nothing is sent.
def stop(self, channels=None):
926    def stop(self, channels=None):
927        """Stop playback. The stopped channels output 0 V.
928
929        Args:
930            channels: Output channel number 1-4, or several as a list,
931                tuple or NumPy array. `None` stops all channels.
932        """
933        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
934        self._write_command(self._OP_STOP, bytes([bits]))

Stop playback. The stopped channels output 0 V.

Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array. None stops all channels.
def set_fixed_voltage(self, channels, voltage):
936    def set_fixed_voltage(self, channels, voltage):
937        """Set output channels to a fixed voltage.
938
939        Stops playback on those channels. The voltage holds until the
940        channel is triggered or stopped.
941
942        Args:
943            channels: Output channel number 1-4, or several as a list,
944                tuple or NumPy array.
945            voltage: Voltage, within `WavePalDevice.output_range`.
946
947        Raises:
948            PulsePalError: If the voltage is outside the output range, or
949                the device rejects the command.
950        """
951        bits = _common.channel_bits(channels)
952        code = int(self._volts_to_codes([voltage])[0])
953        self._write_command(self._OP_SET_FIXED_VOLTAGE,
954                            struct.pack("<BH", bits, code))
955        self._read_ack("set_fixed_voltage()")

Set output channels to a fixed voltage.

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

Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array.
  • voltage: Voltage, within WavePalDevice.output_range.
Raises:
  • PulsePalError: If the voltage is outside the output range, or the device rejects the command.
def status(self):
957    def status(self):
958        """Read the device's playback state.
959
960        Returns:
961            A `DeviceStatus`, with the channels that are playing, the
962            number of samples in each waveform, underrun counts, the
963            longest playback interrupt, each channel's current waveform,
964            and what waits for a param sync edge.
965        """
966        self._write_command(self._OP_GET_STATUS)
967        values = struct.unpack(
968            self._STATUS_FORMAT,
969            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
970        )
971        playing_bits = values[0]
972        stored_load_bits = values[14]
973        return DeviceStatus(
974            playing=[ch for ch in range(1, 5)
975                     if playing_bits & (1 << (ch - 1))],
976            samples_loaded=[None, *values[1:5]],
977            underruns=[None, *values[5:9]],
978            longest_interrupt_us=values[9] / 1000,
979            current_waveform=[None, *values[10:14]],
980            stored_waveforms=[n for n in range(1, 5) if stored_load_bits & (1 << (n - 1))],
981            stored_current_waveform=[None, *values[16:20]] if values[15] else None,
982        )

Read the device's playback state.

Returns:

A DeviceStatus, with the channels that are playing, the number of samples in each waveform, underrun counts, the longest playback interrupt, each channel's current waveform, and what waits for a param sync edge.

def close(self):
1000    def close(self):
1001        """Close the connection to the device.
1002
1003        The device stops playback on all channels, and shows its own name
1004        on its screen again, in place of "PYTHON Connected". It keeps its
1005        settings and waveforms, so TTL triggers still play them. Safe to
1006        call more than once. Called automatically when leaving a `with`
1007        block and when the object is garbage collected, so playback also
1008        stops when the last reference to the object goes.
1009
1010        """
1011        self._close(send_disconnect=True)

Close the connection to the device.

The device stops playback on all channels, and shows its own name on its screen again, in place of "PYTHON Connected". It keeps its settings and waveforms, so TTL triggers still play them. Safe to call more than once. Called automatically when leaving a with block and when the object is garbage collected, so playback also stops when the last reference to the object goes.

class SynthPalDevice:
 288class SynthPalDevice:
 289    """A class to control a Synth Pal on a USB serial port.
 290
 291    Creating an instance opens the serial port, checks that the device
 292    runs Synth Pal firmware, reads its properties into
 293    `SynthPalDevice.info`, shows "PYTHON Connected" on the device's
 294    screen, stops any playback and programs the default settings (see
 295    `SynthPalDevice.set_default_params`).
 296
 297    ```python
 298    from pulsepal import SynthPalDevice
 299
 300    S = SynthPalDevice("COM3")
 301    S.waveform[1] = "Triangle"
 302    S.trigger(1)
 303    S.close()
 304    ```
 305
 306    Replace "COM3" with the device's USB serial port name, which
 307    `SynthPalDevice.serialportlist` lists. `SynthPalDevice` is also a
 308    context manager, which closes the port on exit.
 309
 310    Closing the connection stops playback, as it does on Pulse Pal (each
 311    channel over its off ramp), and puts the device's own name back on its
 312    screen. The device keeps its settings, so TTL triggers still play the
 313    channels.
 314    """
 315
 316    port: "serial.Serial"
 317    """The open `serial.Serial` port connected to the device."""
 318
 319    info: DeviceInfo
 320    """Properties of the connected device. See `DeviceInfo`."""
 321
 322    _CURRENT_FIRMWARE_VERSION = 1
 323    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 324
 325    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 326    _OP_HANDSHAKE = _common.OP_HANDSHAKE
 327    _OP_DISCONNECT = 81
 328    _OP_SET_CLIENT_NAME = 89
 329    _OP_SET_SCREEN_SAVER = 99
 330    _OP_HARDWARE_REVISION = 100
 331    _OP_HARDWARE_INFO = ord("N")
 332    _OP_SET_FREQUENCY = ord("F")
 333    _OP_SET_WAVEFORM = ord("W")
 334    _OP_SET_AMPLITUDE = ord("A")
 335    _OP_SET_RESTING_VOLTAGE = ord("V")
 336    _OP_SET_MEAN_VOLTAGE = ord("M")
 337    _OP_SET_PLAY_DURATION = ord("D")
 338    _OP_SET_ON_RAMP_DURATION = ord("B")
 339    _OP_SET_OFF_RAMP_DURATION = ord("E")
 340    _OP_SET_TRIGGER_LINKS = ord("I")
 341    _OP_SET_TRIGGER_MODE = ord("T")
 342    _OP_SET_ALL_SETTINGS = ord("U")
 343    _OP_GET_ALL_SETTINGS = ord("R")
 344    # Every setting, as ops 85 ('U') and 82 ('R') carry them: frequency (centiHz), waveform codes, amplitudes,
 345    # mean and resting voltages (uV), play, on ramp and off ramp durations (us), links, trigger modes
 346    _SETTINGS_FORMAT = "<I4B4i4i4i4I4I4I8B2B"
 347    _OP_TRIGGER = ord("P")
 348    _OP_STOP = ord("X")
 349    _OP_GET_STATUS = ord("G")
 350    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 351
 352    _HANDSHAKE_REPLY = 83  # 'S'
 353    _HARDWARE_INFO_FORMAT = "<BBIIIII"
 354    _STATUS_FORMAT = "<BI4BII"
 355    _ALL_CHANNELS = 0x0F
 356    # The ops that change settings: if the reply to one is lost, they are read back (_discard_lost_reply())
 357    _SETTING_OPS = frozenset(map(ord, "FWAVMDBEITU"))
 358
 359    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_command())
 360    _unsent = frozenset()  # The settings assigned with auto_sync off since the settings were last sent or read
 361
 362    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 363        """Open a connection to a Synth Pal.
 364
 365        Args:
 366            port_name: USB serial port of the device, such as `COM3` on
 367                Windows or `/dev/ttyACM0` on Linux.
 368            serial_number: Instead of `port_name`, the device's USB serial
 369                number (`DeviceInfo.serial_number`), which finds its port
 370                wherever it is plugged in.
 371            timeout: Serial read timeout, in seconds.
 372
 373        Firmware newer than this module knows is used with a warning: new
 374        firmware only adds commands. Update the package to use what is new.
 375
 376        Raises:
 377            PulsePalError: If the device does not reply to the handshake, or
 378                runs other firmware.
 379            serial.SerialException: If the serial port cannot be opened,
 380                e.g. because another program has it open (on Linux and
 381                macOS, another session of these classes, which lock it).
 382        """
 383        self._closed = True
 384        self._auto_sync = True
 385        self.info = DeviceInfo()
 386        self._frequency = None
 387        self._samples_per_cycle = None
 388        # What the device holds for each output channel's waveform, amplitude (in microvolts: a periodic
 389        # waveform's peak to peak voltage, or a fixed voltage) and mean voltage (in microvolts), as far as this
 390        # object has programmed them. _send_levels() changes them in an order the device accepts.
 391        self._device_waveform = ["Sine"] * 4
 392        self._device_amplitude_uv = [5_000_000] * 4
 393        self._device_mean_uv = [0] * 4
 394        self._waveform = ChannelSettings(
 395            "waveform", ["Sine"] * 4, self, "_apply_waveform")
 396        self._peak_to_peak = ChannelSettings(
 397            "peak_to_peak", [5.0] * 4, self, "_apply_peak_to_peak")
 398        self._fixed_voltage = ChannelSettings(
 399            "fixed_voltage", [5.0] * 4, self, "_apply_fixed_voltage")
 400        self._mean_voltage = ChannelSettings(
 401            "mean_voltage", [0.0] * 4, self, "_apply_mean_voltage")
 402        self._resting_voltage = ChannelSettings(
 403            "resting_voltage", [0.0] * 4, self, "_apply_resting_voltage")
 404        self._play_duration = ChannelSettings(
 405            "play_duration", [1.0] * 4, self, "_apply_play_duration")
 406        self._on_ramp_duration = ChannelSettings(
 407            "on_ramp_duration", [0.0] * 4, self, "_apply_on_ramp_duration")
 408        self._off_ramp_duration = ChannelSettings(
 409            "off_ramp_duration", [0.0] * 4, self, "_apply_off_ramp_duration")
 410        self._trigger_mode = ChannelSettings(
 411            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 412        self._link_trigger_channel1 = ChannelSettings(
 413            "link_trigger_channel1", [False] * 4, self,
 414            "_apply_trigger_channel1_links")
 415        self._link_trigger_channel2 = ChannelSettings(
 416            "link_trigger_channel2", [False] * 4, self,
 417            "_apply_trigger_channel2_links")
 418
 419        port_name = _common.port_name_from(port_name, serial_number, "SynthPalDevice")
 420        self.port = serial.Serial(
 421            port_name,
 422            self._BAUD_RATE,
 423            timeout=timeout,
 424            rtscts=True,
 425            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 426            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 427            # it, such as another of these classes, gets an error instead
 428            exclusive=True,
 429        )
 430        self._closed = False
 431        try:
 432            # Discard anything left in the buffer by an earlier session
 433            self.port.reset_input_buffer()
 434            self._handshake()
 435            self._read_hardware_info()
 436            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 437            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 438            self.stop()
 439            self.set_default_params()
 440        except BaseException:
 441            # Op 81 means something else to other devices, so it is sent
 442            # only once the device has identified itself as a Synth Pal
 443            self._close(send_disconnect=self.info.firmware_version is not None)
 444            raise
 445
 446    @staticmethod
 447    def serialportlist(ports_to_list="available"):
 448        """Return the names of the USB serial ports on this computer.
 449
 450        Called on the class, without connecting to a device, to find the
 451        port name to pass to `SynthPalDevice`.
 452
 453        Args:
 454            ports_to_list: `available` to list only the ports that are
 455                not already in use, or `all` to list every USB serial
 456                port. Not case sensitive.
 457
 458        Returns:
 459            Sorted list of port names, such as `["COM3", "COM7"]`.
 460
 461        Raises:
 462            PulsePalError: If `ports_to_list` is not `available` or `all`.
 463        """
 464        return _common.serialportlist(ports_to_list)
 465
 466    # ------------------------------------------------------------------
 467    # Settings
 468    # ------------------------------------------------------------------
 469
 470    def set_default_params(self):
 471        """Program the default settings on the device.
 472
 473        The defaults are a frequency of 100 Hz, and on every output
 474        channel a sine wave of 5 V peak to peak around a mean voltage of
 475        0 V, resting at 0 V, played for 1 second with no ramps, and a
 476        fixed voltage of 5 V for when the waveform is "Fixed Voltage".
 477        Both trigger channels are in normal mode, and no output channel is
 478        linked to a trigger channel, so a TTL starts nothing until a script
 479        links it. They match the settings the device starts with. They are sent at once,
 480        also while `SynthPalDevice.auto_sync` is off.
 481        """
 482        auto_sync = self._auto_sync
 483        self._auto_sync = True
 484        try:
 485            self.frequency = 100
 486            # In this order, each is valid whatever the device holds: a mean of 0 V goes with any
 487            # amplitude, 5 V is then a valid amplitude for any waveform, and a sine wave is then valid
 488            self._send_levels_in_order((("M", [0] * 4), ("A", [5_000_000] * 4), ("W", ["Sine"] * 4)),
 489                                       force=True)
 490            for settings, value in ((self._waveform, "Sine"), (self._peak_to_peak, 5.0),
 491                                    (self._fixed_voltage, 5.0), (self._mean_voltage, 0.0)):
 492                settings._store([value] * 4)
 493            self.resting_voltage = [0] * 4
 494            self.play_duration = [1] * 4
 495            self.on_ramp_duration = [0] * 4
 496            self.off_ramp_duration = [0] * 4
 497            self.trigger_mode = ["Normal"] * 2
 498            self._set_trigger_links([False] * 4, [False] * 4)
 499            self._unsent = frozenset()  # Every setting has been sent
 500        finally:
 501            self._auto_sync = auto_sync
 502
 503    @property
 504    def frequency(self):
 505        """Frequency of all output channels, in Hz.
 506
 507        1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during
 508        playback: playing channels carry on from the same point in their
 509        cycle, at the new frequency, and keep the time they have left to
 510        play. The frequency played is exact: the device's sample clock is a
 511        whole multiple of it (see `SynthPalDevice.sampling_rate`). For
 512        `"White Noise"` it sets only the sampling rate.
 513        """
 514        return self._frequency
 515
 516    @frequency.setter
 517    def frequency(self, value):
 518        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
 519                or not math.isfinite(value):
 520            raise PulsePalError(
 521                f"frequency must be a number of Hz. Received {value!r}.")
 522        centihz = round(value * 100)
 523        low = round(self.info.min_frequency * 100)
 524        high = round(self.info.max_frequency * 100)
 525        if not low <= centihz <= high:
 526            raise PulsePalError(
 527                f"frequency must be {self.info.min_frequency:g} to "
 528                f"{self.info.max_frequency:g} Hz. Received {value!r}."
 529            )
 530        if self._auto_sync:
 531            self._write_command(self._OP_SET_FREQUENCY,
 532                                struct.pack("<I", centihz))
 533            confirmed, samples_per_cycle = struct.unpack(
 534                "<BI", self._read_raw(5))
 535            if confirmed != 1:
 536                raise PulsePalError(
 537                    f"Synth Pal rejected the frequency {value!r} Hz.")
 538        else:
 539            samples_per_cycle = _samples_per_cycle(centihz)
 540            self._unsent = self._unsent | {"frequency"}  # Until sync_to_device() sends it (see auto_sync)
 541        self._frequency = centihz / 100
 542        self._samples_per_cycle = samples_per_cycle
 543
 544    @property
 545    def auto_sync(self):
 546        """Whether assigning a setting programs the device at once.
 547
 548        `True` (the default): each assignment, such as
 549        `S.peak_to_peak[1] = 2`, programs the device at once, as the
 550        device's own op for that setting does, also while a trigger
 551        channel is in param sync mode.
 552
 553        `False`: assignments change only this object's copy of the settings,
 554        checking each value; `SynthPalDevice.sync_to_device` then sends all
 555        of them at once. Use it to store the next trial's settings for a
 556        param sync edge (see `SynthPalDevice.trigger_mode`), or to change
 557        several settings in one command. Turning it back on does not send
 558        changes made meanwhile, so it raises `pulsepal.PulsePalError` while
 559        there are any: call `SynthPalDevice.sync_to_device` first to send
 560        them, or `SynthPalDevice.sync_from_device` to discard them. A
 561        `SynthPalDevice.batch` block does it all for you. `close()` warns of
 562        changes never sent. `trigger`, `stop`, `status` and
 563        `set_default_params` act at once either way.
 564        """
 565        return self._auto_sync
 566
 567    @auto_sync.setter
 568    def auto_sync(self, value):
 569        value = to_bool(value, "auto_sync")
 570        if value and self._unsent:
 571            # Turned on, auto_sync would leave them unsent, and the device playing its old settings
 572            raise PulsePalError(
 573                f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call "
 574                "sync_to_device() to send them, or sync_from_device() to discard them, before turning auto_sync on."
 575            )
 576        self._auto_sync = value
 577
 578    def batch(self):
 579        """Change several settings, and send them in one command.
 580
 581        Returns a context manager. In its block, assignments change only
 582        this object's copy of the settings (as with
 583        `SynthPalDevice.auto_sync` off), and when the block ends,
 584        `SynthPalDevice.sync_to_device` sends all of them at once. If the
 585        block raises an error, nothing is sent, and the settings go back
 586        to what they were.
 587
 588        ```python
 589        with S.batch():
 590            S.frequency = 880
 591            S.waveform = ["Triangle"] * 4
 592            S.peak_to_peak = [2, 4, 6, 8]
 593        ```
 594
 595        In param sync mode, the device stores the settings for the next
 596        rising edge on the param sync channel (see
 597        `SynthPalDevice.trigger_mode`).
 598        """
 599        return _common.batch(self)
 600
 601    def sync_from_device(self):
 602        """Read every setting from the device into this object.
 603
 604        Use it after settings were changed with the device's joystick, for
 605        example. The device holds one amplitude per channel: a Fixed
 606        Voltage channel's goes to `SynthPalDevice.fixed_voltage`, and any
 607        other channel's to `SynthPalDevice.peak_to_peak`. This object keeps
 608        the other one, reduced if need be to suit the mean voltage read
 609        back. In param sync mode, it reads the settings the device plays
 610        now, not a set waiting for the next edge. Changes made with
 611        `SynthPalDevice.auto_sync` off and not yet sent are discarded.
 612
 613        Raises:
 614            PulsePalError: If the device does not reply.
 615        """
 616        self._write_command(self._OP_GET_ALL_SETTINGS)
 617        values = struct.unpack(self._SETTINGS_FORMAT, self._read_raw(struct.calcsize(self._SETTINGS_FORMAT)))
 618        centihz = values[0]
 619        waveforms = [WAVEFORMS[code] for code in values[1:5]]
 620        amplitudes, means = list(values[5:9]), list(values[9:13])
 621        peak_to_peak, fixed_voltage = list(self._peak_to_peak)[1:], list(self._fixed_voltage)[1:]
 622        for i, (name, amplitude, mean) in enumerate(zip(waveforms, amplitudes, means)):
 623            if name == FIXED_VOLTAGE:
 624                fixed_voltage[i] = amplitude / 1e6
 625                # This object's own peak to peak voltage must still suit the mean voltage (see _check_levels())
 626                peak_to_peak[i] = min(peak_to_peak[i], (2 * _MAX_VOLTAGE_UV - 2 * abs(mean)) / 1e6)
 627            else:
 628                peak_to_peak[i] = amplitude / 1e6
 629        self._frequency = centihz / 100
 630        self._samples_per_cycle = _samples_per_cycle(centihz)
 631        self._waveform._store(waveforms)
 632        self._peak_to_peak._store(peak_to_peak)
 633        self._fixed_voltage._store(fixed_voltage)
 634        self._mean_voltage._store([mean / 1e6 for mean in means])
 635        self._resting_voltage._store([rest / 1e6 for rest in values[13:17]])
 636        self._play_duration._store([d / 1e6 for d in values[17:21]])
 637        self._on_ramp_duration._store([d / 1e6 for d in values[21:25]])
 638        self._off_ramp_duration._store([d / 1e6 for d in values[25:29]])
 639        self._link_trigger_channel1._store([bool(link) for link in values[29:33]])
 640        self._link_trigger_channel2._store([bool(link) for link in values[33:37]])
 641        self._trigger_mode._store([TRIGGER_MODES[code] for code in values[37:39]])
 642        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
 643        self._unsent = frozenset()  # Every setting is read
 644
 645    def export_params(self):
 646        """Return every setting, as a dict of plain values and lists.
 647
 648        Keyed by setting name: `"frequency"`, then the channel settings,
 649        each with one value per channel and no unused index 0. It holds
 650        only numbers, booleans and names, so it can be saved with `json`
 651        and logged with your data, to record exactly what the device
 652        played.
 653
 654        ```python
 655        import json
 656        with open("trial_settings.json", "w") as f:
 657            json.dump(S.export_params(), f)
 658        ```
 659        """
 660        params = {"frequency": self._frequency}
 661        params.update({name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS})
 662        return params
 663
 664    def import_params(self, params):
 665        """Program the device with settings exported by `SynthPalDevice.export_params`.
 666
 667        All of them are checked first, and then sent in one command, as
 668        at the end of a `SynthPalDevice.batch` block: in param sync mode,
 669        the device stores them for the next sync edge. The levels
 670        (`waveform`, `peak_to_peak`, `fixed_voltage`, `mean_voltage`) are
 671        checked together, so their order in `params` does not matter.
 672        Settings missing from `params` keep their values. A name that is
 673        not a setting is skipped with a warning, so that settings exported
 674        by a newer version of this package, which may have more, can still
 675        be imported.
 676
 677        ```python
 678        import json
 679        with open("trial_settings.json") as f:
 680            S.import_params(json.load(f))
 681        ```
 682
 683        Args:
 684            params: A dict of setting name to its value: a number for
 685                `"frequency"`, and one value per channel (a list, tuple or
 686                NumPy array) for the others, as `export_params` returns.
 687
 688        Raises:
 689            PulsePalError: If a value is invalid. Nothing is sent, and the
 690                settings keep the values they had.
 691        """
 692        names = {"frequency", *_CHANNEL_SETTINGS}
 693        unknown = [name for name in params if name not in names]
 694        if unknown:
 695            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
 696                          "SynthPalDevice does not have.", stacklevel=2)
 697        levels = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")
 698        with self.batch():
 699            for name, values in params.items():
 700                if name in levels:
 701                    # Each is checked alone here, and the levels together when the block ends (_check_sync()):
 702                    # an assignment would check each against the others' old values
 703                    getattr(self, name)._store(
 704                        self._normalize(name, _common.one_value_per_channel(name, values, 4)))
 705                elif name in names:
 706                    setattr(self, name, values)
 707
 708    def sync_to_device(self):
 709        """Send every setting to the device in one command.
 710
 711        While a trigger channel is in param sync mode, the device stores
 712        the set, replacing any set stored before, and that channel's next
 713        rising edge loads it (see `SynthPalDevice.trigger_mode`). Otherwise
 714        the device applies it at once. The settings sent are this object's:
 715        `frequency`, and for each channel `waveform`, `peak_to_peak` or
 716        `fixed_voltage`, `mean_voltage`, `resting_voltage`,
 717        `play_duration`, `on_ramp_duration`, `off_ramp_duration`,
 718        `link_trigger_channel1` and `link_trigger_channel2`, and
 719        `trigger_mode`. They read as sent, including while a set waits for
 720        its edge.
 721
 722        Raises:
 723            PulsePalError: If the device rejects the set.
 724        """
 725        self._check_sync()
 726        self._send_sync()
 727
 728    def configure(self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None,
 729                  mean_voltage=None, resting_voltage=None, play_duration=None,
 730                  on_ramp_duration=None, off_ramp_duration=None):
 731        """Set several of the output channels' settings at once.
 732
 733        Settings left out keep their values. The new settings are checked
 734        together, so their order does not matter: for example, a channel
 735        whose sine wave swings 2 V around a mean voltage of 9 V can go
 736        straight to 20 V peak to peak around 0 V. With
 737        `SynthPalDevice.auto_sync` on, the class programs the device in an
 738        order it accepts at every step.
 739
 740        ```python
 741        S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
 742        S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
 743                    play_duration=0.1)
 744        ```
 745
 746        Args:
 747            channels: Output channel number 1-4, or several as a list,
 748                tuple or NumPy array.
 749            waveform, peak_to_peak, fixed_voltage, mean_voltage,
 750                resting_voltage, play_duration, on_ramp_duration,
 751                off_ramp_duration: New values, as for the settings of the
 752                same names: one value for every channel given, or a list
 753                with one value per channel given, in the same order.
 754
 755        Raises:
 756            PulsePalError: If a value is out of range, or the settings do
 757                not go together (nothing is sent), or the device rejects
 758                them.
 759        """
 760        channels = _common.channel_numbers(channels)
 761        if len(set(channels)) != len(channels):
 762            raise PulsePalError(f"configure(): channels must be distinct. Received {channels!r}.")
 763        requested = {"waveform": waveform, "peak_to_peak": peak_to_peak, "fixed_voltage": fixed_voltage,
 764                     "mean_voltage": mean_voltage, "resting_voltage": resting_voltage,
 765                     "play_duration": play_duration, "on_ramp_duration": on_ramp_duration,
 766                     "off_ramp_duration": off_ramp_duration}
 767        new_values = {}
 768        for name, value in requested.items():
 769            if value is None:
 770                continue
 771            values = list(value) if isinstance(value, (list, tuple, np.ndarray)) else [value]
 772            if len(values) == 1:
 773                values = values * len(channels)
 774            if len(values) != len(channels):
 775                raise PulsePalError(
 776                    f"configure(): {name} has {len(values)} values for {len(channels)} channels. Give one "
 777                    "value, or one value per channel."
 778                )
 779            settings = list(getattr(self, name))[1:]
 780            for channel, item in zip(channels, values):
 781                settings[channel - 1] = item
 782            new_values[name] = self._normalize(name, settings)
 783        levels = {name: new_values.get(name, list(getattr(self, name))[1:])
 784                  for name in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
 785        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], "configure")
 786        if self._auto_sync:
 787            self._send_levels(*self._device_levels(**levels))
 788        else:
 789            self._unsent = self._unsent | set(new_values)  # Until sync_to_device() sends them (see auto_sync)
 790        for name in levels:
 791            if name in new_values:
 792                getattr(self, name)._store(new_values[name])
 793        ops = {"resting_voltage": self._OP_SET_RESTING_VOLTAGE, "play_duration": self._OP_SET_PLAY_DURATION,
 794               "on_ramp_duration": self._OP_SET_ON_RAMP_DURATION,
 795               "off_ramp_duration": self._OP_SET_OFF_RAMP_DURATION}
 796        for name, op in ops.items():
 797            if name in new_values:
 798                if self._auto_sync:
 799                    self._send_setting(name, op, new_values[name])
 800                getattr(self, name)._store(new_values[name])
 801
 802    @property
 803    def samples_per_cycle(self):
 804        """Samples in one cycle of the waveform, at `frequency`.
 805
 806        The largest multiple of 4 whose sampling rate is at most
 807        `DeviceInfo.max_sampling_rate` (100 kHz), so that a sample falls
 808        on every edge, peak and trough of every waveform. For example,
 809        332 at 300 Hz and 4 at 20 kHz.
 810        """
 811        return self._samples_per_cycle
 812
 813    @property
 814    def sampling_rate(self):
 815        """The rate at which the device plays samples, in Hz.
 816
 817        `samples_per_cycle` times `frequency`: 100 kHz or just below it,
 818        and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at
 819        300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide
 820        every rate exactly, so sample periods differ by up to one tick of
 821        its 24 MHz clock (42 ns), arranged so that the frequency is exact.
 822        """
 823        return self._samples_per_cycle * self._frequency
 824
 825    @property
 826    def waveform(self):
 827        """The waveform of each output channel.
 828
 829        Indexed by channel number (see "Channel settings" above). Each
 830        cycle starts at the trigger:
 831
 832        - `"Sine"` and `"Triangle"`: start at the mean voltage, rising.
 833        - `"Square"`: high for the first half of each cycle, then low.
 834        - `"Sawtooth"`: rises from its lowest voltage to its highest, then
 835          falls back at the end of the cycle.
 836        - `"Fixed Voltage"`: steps to the channel's
 837          `SynthPalDevice.fixed_voltage` for the play duration, and
 838          returns to the resting voltage. It is not periodic, so the
 839          frequency does not change it.
 840        - `"White Noise"`: a new random voltage on every sample, uniform
 841          over the peak to peak voltage around the mean voltage. It is
 842          rendered at the sampling rate the frequency sets
 843          (`SynthPalDevice.sampling_rate`), so no claim is made about its
 844          spectrum. Each channel has its own random sequence, which carries
 845          on from one playback to the next: every playback is new noise.
 846          The sequences start from the same point when the device powers
 847          up.
 848
 849        The periodic waveforms and white noise swing
 850        `SynthPalDevice.peak_to_peak` around `SynthPalDevice.mean_voltage`.
 851        Names are not case sensitive. A change applies to playback in
 852        progress.
 853        """
 854        return self._waveform
 855
 856    @waveform.setter
 857    def waveform(self, values):
 858        self._waveform._assign(values)
 859
 860    @property
 861    def peak_to_peak(self):
 862        """The peak to peak voltage of each output channel's waveform, in volts.
 863
 864        Indexed by channel number (see "Channel settings" above). 0 to
 865        20 V: the waveform swings `peak_to_peak / 2` above and below the
 866        channel's `SynthPalDevice.mean_voltage`, so it must stay within
 867        -10 V to 10 V: `abs(mean_voltage) + peak_to_peak / 2 <= 10`. To
 868        raise it beyond what the mean voltage allows, change the mean
 869        voltage first, or set both with `SynthPalDevice.configure`.
 870
 871        A sine wave of 4 V peak to peak swings from -2 V to 2 V around a
 872        mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak
 873        to peak is uniform from -2 V to 2 V. A `"Fixed Voltage"` channel
 874        keeps its peak to peak voltage for when it plays a periodic waveform
 875        again.
 876
 877        Set to the nearest microvolt. A change applies to playback in
 878        progress.
 879        """
 880        return self._peak_to_peak
 881
 882    @peak_to_peak.setter
 883    def peak_to_peak(self, values):
 884        self._peak_to_peak._assign(values)
 885
 886    @property
 887    def fixed_voltage(self):
 888        """The voltage each output channel steps to when its waveform is
 889        `"Fixed Voltage"`, in volts.
 890
 891        Indexed by channel number (see "Channel settings" above). -10 to
 892        10 V, with any resting voltage and mean voltage. A channel playing
 893        another waveform keeps its fixed voltage for when its waveform is
 894        `"Fixed Voltage"` again.
 895
 896        Set to the nearest microvolt. A change applies to playback in
 897        progress.
 898        """
 899        return self._fixed_voltage
 900
 901    @fixed_voltage.setter
 902    def fixed_voltage(self, values):
 903        self._fixed_voltage._assign(values)
 904
 905    @property
 906    def mean_voltage(self):
 907        """The mean voltage of each output channel's waveform, in volts.
 908
 909        Indexed by channel number (see "Channel settings" above). -10 to
 910        10 V. A periodic waveform, or white noise, swings around it at full
 911        amplitude, and must stay within -10 V to 10 V with the
 912        `peak_to_peak` voltage: `abs(mean_voltage) + peak_to_peak / 2 <= 10`.
 913        It may differ from the `resting_voltage`, which the channel
 914        outputs between playbacks: the ramps move the mean between the two
 915        (see "Ramps" above). A `"Fixed Voltage"` ignores it. Set to the
 916        nearest microvolt. A change applies to playback in progress.
 917        """
 918        return self._mean_voltage
 919
 920    @mean_voltage.setter
 921    def mean_voltage(self, values):
 922        self._mean_voltage._assign(values)
 923
 924    @property
 925    def resting_voltage(self):
 926        """The resting voltage of each output channel, in volts.
 927
 928        Indexed by channel number (see "Channel settings" above). -10 to
 929        10 V, with any waveform. The channel outputs it while idle, and
 930        its ramps start and end there (see "Ramps" above). Set to the
 931        nearest microvolt. A change applies to playback in progress.
 932        """
 933        return self._resting_voltage
 934
 935    @resting_voltage.setter
 936    def resting_voltage(self, values):
 937        self._resting_voltage._assign(values)
 938
 939    @property
 940    def play_duration(self):
 941        """How long each output channel plays after a trigger, in seconds.
 942
 943        Indexed by channel number (see "Channel settings" above). Up to
 944        `DeviceInfo.max_play_duration` (3600 s). `0` plays until the
 945        channel is stopped. It counts at full amplitude, after the on
 946        ramp. The channel stops when the duration has elapsed, part way
 947        through a cycle if need be, and returns to its resting voltage,
 948        over its off ramp. Durations are counted in samples, rounded to
 949        the nearest one, and a nonzero duration lasts at least one sample.
 950        A change applies to playback in progress.
 951        """
 952        return self._play_duration
 953
 954    @play_duration.setter
 955    def play_duration(self, values):
 956        self._play_duration._assign(values)
 957
 958    @property
 959    def on_ramp_duration(self):
 960        """How long each output channel fades in after a trigger, in seconds.
 961
 962        Indexed by channel number (see "Channel settings" above). Up to
 963        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 964        ramp. Over it, the peak to peak voltage rises linearly from 0 V to
 965        `peak_to_peak`, and the mean from the `resting_voltage` to the
 966        `mean_voltage`, before the play duration (see "Ramps" above).
 967        Counted in samples, as `play_duration` is. A change applies to
 968        playback in progress: a ramp under way keeps the level it has
 969        reached, and goes on at the new rate.
 970        """
 971        return self._on_ramp_duration
 972
 973    @on_ramp_duration.setter
 974    def on_ramp_duration(self, values):
 975        self._on_ramp_duration._assign(values)
 976
 977    @property
 978    def off_ramp_duration(self):
 979        """How long each output channel fades out when it stops, in seconds.
 980
 981        Indexed by channel number (see "Channel settings" above). Up to
 982        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 983        ramp. Over it, the peak to peak voltage falls linearly to 0 V, and
 984        the mean to the `resting_voltage`, after the play duration or a
 985        stop (see "Ramps" above). Counted in samples, as `play_duration`
 986        is. A change applies to playback in progress.
 987        """
 988        return self._off_ramp_duration
 989
 990    @off_ramp_duration.setter
 991    def off_ramp_duration(self, values):
 992        self._off_ramp_duration._assign(values)
 993
 994    @property
 995    def trigger_mode(self):
 996        """How each trigger channel acts on the output channels linked to it.
 997
 998        Indexed by trigger channel number, 1 or 2 (index 0 is unused).
 999        These are Pulse Pal's trigger modes:
1000
1001        - `"Normal"`: a rising edge starts the linked channels. Channels
1002          that are playing ignore it.
1003        - `"Toggle"`: a rising edge starts the linked channels, or stops
1004          those that are playing.
1005        - `"Gated"`: a rising edge starts the linked channels, and a
1006          falling edge stops them, unless the other trigger channel is
1007          also gated, linked to them, and still high. With a play
1008          duration of 0, a channel plays for exactly as long as the TTL is
1009          high.
1010        - `"Param Sync"`: a rising edge starts and stops nothing (the
1011          channel's links are ignored). It loads the settings most recently
1012          sent by `SynthPalDevice.sync_to_device` (or at the end of a
1013          `SynthPalDevice.batch` block), if any. This is how the next
1014          trial's settings are sent during the current trial and applied
1015          the instant it starts.
1016
1017        At a param sync edge, the frequency and both trigger modes change
1018        at once: they are shared by all channels. An output channel at its
1019        resting voltage takes its new settings at once. One that is playing
1020        (off ramp included) finishes on the settings it started with, at
1021        the new frequency, and takes the new ones the moment it reaches its
1022        resting voltage, so the next trigger plays them. A trigger mode
1023        sent by `SynthPalDevice.sync_to_device` applies from the next
1024        edge.
1025
1026        Only `SynthPalDevice.sync_to_device` is held back for the edge:
1027        with `SynthPalDevice.auto_sync` on, every assignment programs the
1028        device at once, also in param sync mode. So leaving param sync mode
1029        means assigning `trigger_mode` with `auto_sync` on. When no trigger
1030        channel is left in the mode, the device discards a stored set.
1031
1032        To start channels on the same edge, wire the TTL to the other
1033        trigger channel too: the settings load first, so the channels it
1034        starts play the new settings. Connecting, and `set_default_params`,
1035        take both trigger channels out of param sync mode.
1036
1037        Names are not case sensitive.
1038        """
1039        return self._trigger_mode
1040
1041    @trigger_mode.setter
1042    def trigger_mode(self, values):
1043        self._trigger_mode._assign(values)
1044
1045    @property
1046    def link_trigger_channel1(self):
1047        """Whether each output channel is triggered by trigger channel 1.
1048
1049        Indexed by channel number (see "Channel settings" above). By
1050        default no output channel is linked to either trigger channel, so
1051        a TTL starts nothing until a script links it.
1052        """
1053        return self._link_trigger_channel1
1054
1055    @link_trigger_channel1.setter
1056    def link_trigger_channel1(self, values):
1057        self._link_trigger_channel1._assign(values)
1058
1059    @property
1060    def link_trigger_channel2(self):
1061        """Whether each output channel is triggered by trigger channel 2.
1062
1063        Indexed by channel number (see "Channel settings" above).
1064        """
1065        return self._link_trigger_channel2
1066
1067    @link_trigger_channel2.setter
1068    def link_trigger_channel2(self, values):
1069        self._link_trigger_channel2._assign(values)
1070
1071    def set_screen_saver(self, enabled, timeout=1800):
1072        """Switch the device's screen saver on or off, and set its timeout.
1073
1074        With the screen saver on, the device dims its screen once it has
1075        been left alone for `timeout` seconds: no command from a computer,
1076        no rising edge on a trigger channel, and no joystick click or
1077        push. The next of these brings the screen back. The device keeps
1078        both settings in its EEPROM, shared with Pulse Pal firmware, and
1079        saves them once no channel is playing. A new device starts with
1080        the screen saver on and 1800 s.
1081
1082        Args:
1083            enabled: `True` to switch the screen saver on, `False` to
1084                switch it off.
1085            timeout: Seconds without activity before the screen dims, 1 to
1086                65535.
1087
1088        Raises:
1089            PulsePalError: If a value is out of range.
1090        """
1091        state = to_bool(enabled, "enabled")
1092        if isinstance(timeout, (bool, np.bool_)) \
1093                or not isinstance(timeout, numbers.Integral) \
1094                or not 1 <= timeout <= 65535:
1095            raise PulsePalError(
1096                "timeout must be a whole number of seconds from 1 to 65535. "
1097                f"Received {timeout!r}."
1098            )
1099        self._write_command(self._OP_SET_SCREEN_SAVER,
1100                            struct.pack("<BH", int(state), int(timeout)))
1101        self._read_ack("set_screen_saver()")
1102
1103    # ------------------------------------------------------------------
1104    # Playback
1105    # ------------------------------------------------------------------
1106
1107    def trigger(self, channels):
1108        """Trigger output channels in software.
1109
1110        Channels that are idle start from the beginning of their waveform
1111        cycle, together. Channels that are playing ignore it, as they
1112        ignore a trigger channel in normal mode.
1113
1114        ```python
1115        S.trigger(1)
1116        S.trigger([2, 4])     # a list, tuple or NumPy array
1117        ```
1118
1119        Args:
1120            channels: Output channel number 1-4, or several as a list,
1121                tuple or NumPy array.
1122        """
1123        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channels)]))
1124
1125    def stop(self, channels=None):
1126        """Stop playback. The stopped channels return to their resting
1127        voltage, over their off ramps.
1128
1129        Args:
1130            channels: Output channel number 1-4, or several as a list,
1131                tuple or NumPy array. `None` stops all channels.
1132        """
1133        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
1134        self._write_command(self._OP_STOP, bytes([bits]))
1135
1136    def status(self):
1137        """Read the device's playback state.
1138
1139        Returns:
1140            A `DeviceStatus`, with the channels that are playing, the
1141            samples per cycle, each channel's output range, and timing
1142            figures.
1143        """
1144        self._write_command(self._OP_GET_STATUS)
1145        values = struct.unpack(
1146            self._STATUS_FORMAT,
1147            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
1148        )
1149        playing_bits = values[0]
1150        range_names = list(OUTPUT_RANGES)
1151        return DeviceStatus(
1152            playing=[ch for ch in range(1, 5)
1153                     if playing_bits & (1 << (ch - 1))],
1154            samples_per_cycle=values[1],
1155            output_ranges=[None, *(range_names[i] if i < len(range_names)
1156                                   else None for i in values[2:6])],
1157            longest_interrupt_us=values[6] / 1000,
1158            late_updates=values[7],
1159        )
1160
1161    def _playback_checksums(self):
1162        """For testing: what each channel played since it last started.
1163
1164        Returns two lists indexed by channel number, with index 0 unused:
1165        the number of samples played, and the sum of their DAC codes
1166        modulo 2**32. `tests/synthpal_hardware_test.py` compares them with
1167        the samples it expects, to check what the device played.
1168        """
1169        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
1170        values = struct.unpack("<8I", self._read_raw(32))
1171        return [None, *values[:4]], [None, *values[4:]]
1172
1173    # ------------------------------------------------------------------
1174    # Connection
1175    # ------------------------------------------------------------------
1176
1177    def close(self):
1178        """Close the connection to the device.
1179
1180        The device stops playback on all channels, each over its off ramp,
1181        and shows its own name on its screen again, in place of "PYTHON
1182        Connected". It keeps its settings, so TTL triggers still play the
1183        channels. Safe to call more than once. Called automatically when
1184        leaving a `with` block and when the object is garbage collected,
1185        so playback also stops when the last reference to the object
1186        goes.
1187
1188        """
1189        self._close(send_disconnect=True)
1190
1191    def _close(self, send_disconnect):
1192        """Close the connection. send_disconnect: tell the device first (op 81). False when the device may not
1193        run this firmware: op 81 means something else to other devices."""
1194        if getattr(self, "_closed", True):
1195            return
1196        self._closed = True
1197        if send_disconnect and self._unsent:
1198            _common.warn(f"SynthPalDevice closed with changes made with auto_sync off that were never sent to the "
1199                         f"device: {self._unsent_names()}.")
1200        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1201        try:
1202            if send_disconnect and self.port and self.port.is_open:
1203                self._write_command(self._OP_DISCONNECT)
1204        except Exception:
1205            pass  # The port may already be gone, e.g. the cable was unplugged
1206        finally:
1207            if self.port and self.port.is_open:
1208                self.port.close()
1209
1210    def __enter__(self):
1211        """Enter a `with` block, returning the connected device."""
1212        return self
1213
1214    def __exit__(self, exc_type, exc_value, traceback):
1215        """Close the port when leaving a `with` block.
1216
1217        Returns:
1218            `False`, so any exception raised in the block propagates.
1219        """
1220        self.close()
1221        return False
1222
1223    def __del__(self):
1224        """Close the port when the object is collected."""
1225        try:
1226            self.close()
1227        except Exception:
1228            # Destructors should not raise; the serial object may already be
1229            # gone during interpreter shutdown.
1230            pass
1231
1232    def __setattr__(self, name, value):
1233        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1234        _common.refuse_unknown_attribute())."""
1235        if name[0] != "_":
1236            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1237        super().__setattr__(name, value)
1238
1239    def __repr__(self):
1240        """Describe the device and its settings, e.g. with `print(S)`."""
1241        port = getattr(getattr(self, "port", None), "port", None)
1242        lines = [
1243            f"SynthPalDevice on {port} (Synth Pal firmware v{self.info.firmware_version})",
1244            f"frequency: {self._frequency} Hz ({self._samples_per_cycle} samples per cycle)",
1245            f"auto_sync: {self._auto_sync}",
1246        ]
1247        lines += [f"{name}: {list(getattr(self, name))}" for name in _CHANNEL_SETTINGS]
1248        return "\n".join(lines)
1249
1250    # ------------------------------------------------------------------
1251    # Internals: settings
1252    # ------------------------------------------------------------------
1253
1254    def _handshake(self):
1255        """Check that the device runs a supported Synth Pal firmware."""
1256        self._write_command(self._OP_HANDSHAKE)
1257        handshake = _common.read_handshake_reply(self.port)  # Skips a reply to an earlier session's command
1258        if handshake is None:
1259            raise PulsePalError(
1260                f"No reply from the device on {self.port.port}. Is it a "
1261                "Pulse Pal 3 running Synth Pal firmware?"
1262                f" {_common.NO_REPLY_HINT}"
1263            )
1264        reply, version = handshake
1265        if reply != self._HANDSHAKE_REPLY:
1266            if reply in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
1267                raise _common.other_firmware_error(reply, version, self.port.port, "Synth Pal")
1268            raise PulsePalError(
1269                "Incorrect handshake returned. Expected "
1270                f"{self._HANDSHAKE_REPLY}, received {reply}."
1271            )
1272        if version > self._CURRENT_FIRMWARE_VERSION:
1273            # New firmware only adds commands (see PROTOCOL.md), so this class still works with it
1274            warnings.warn(
1275                f"Synth Pal firmware v{version} is newer than this pulsepal package knows "
1276                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what is "
1277                "new.", stacklevel=3)
1278        self.info.firmware_version = version
1279
1280    def _read_hardware_info(self):
1281        self._write_command(self._OP_HARDWARE_INFO)
1282        (
1283            self.info.hardware_version,
1284            self.info.n_channels,
1285            min_centihz,
1286            max_centihz,
1287            self.info.max_sampling_rate,
1288            self.info.timer_clock_hz,
1289            max_duration_us,
1290        ) = struct.unpack(
1291            self._HARDWARE_INFO_FORMAT,
1292            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
1293        )
1294        self.info.min_frequency = min_centihz / 100
1295        self.info.max_frequency = max_centihz / 100
1296        self.info.max_play_duration = max_duration_us / 1e6
1297        self._write_command(self._OP_HARDWARE_REVISION)
1298        self.info.circuit_board_revision = self._read_raw(1)[0]
1299        self._write_command(_common.OP_SERIAL_NUMBER)
1300        self.info.serial_number = _common.read_serial_number(self._read_raw)
1301
1302    def _normalize(self, name, values):
1303        """Check new values for all four channels of a setting, and return them as its list holds them."""
1304        if name == "waveform":
1305            return [to_name(value, WAVEFORMS, "waveform") for value in values]
1306        if name == "peak_to_peak":
1307            return [self._to_volts(value, name, 0, 20, " peak to peak") for value in values]
1308        if name in ("fixed_voltage", "mean_voltage", "resting_voltage"):
1309            return [self._to_volts(value, name, -10, 10) for value in values]
1310        zero_meaning = "0 (play until stopped)" if name == "play_duration" else "0 (no ramp)"
1311        return [self._to_seconds(value, name, zero_meaning) for value in values]
1312
1313    def _check_levels(self, peak_to_peak, mean_voltage, setting):
1314        """Check that each channel's periodic waveform stays within -10 V to 10 V, as the device does.
1315
1316        Checked on every channel, also one playing a fixed voltage, so that a change of waveform always
1317        suits the levels it finds. `setting` is the one being changed, for the advice in the message.
1318        """
1319        for channel, (pp, mean) in enumerate(zip(peak_to_peak, mean_voltage), start=1):
1320            pp_uv, mean_uv = _microvolts(pp), _microvolts(mean)
1321            if 2 * abs(mean_uv) + pp_uv > 2 * _MAX_VOLTAGE_UV:
1322                advice = {
1323                    "peak_to_peak": "Change mean_voltage first, set both with configure(), or choose a "
1324                                    "smaller peak_to_peak.",
1325                    "mean_voltage": "Change peak_to_peak first, set both with configure(), or choose a "
1326                                    "smaller mean_voltage.",
1327                }.get(setting, "Change peak_to_peak or mean_voltage.")
1328                raise PulsePalError(
1329                    f"On channel {channel}, a mean voltage of {mean:g} V and a peak to peak voltage of "
1330                    f"{pp:g} V would reach {(abs(mean_uv) + pp_uv / 2) / 1e6:g} V. The waveform must "
1331                    f"stay within -10 V to 10 V. {advice}"
1332                )
1333
1334    def _device_levels(self, waveform, peak_to_peak, fixed_voltage, mean_voltage):
1335        """What the device holds for these settings: waveforms, amplitudes in microvolts (a fixed voltage, or a
1336        periodic waveform's peak to peak voltage) and mean voltages in microvolts."""
1337        amplitudes = [_microvolts(fixed if name == FIXED_VOLTAGE else pp)
1338                      for name, pp, fixed in zip(waveform, peak_to_peak, fixed_voltage)]
1339        return list(waveform), amplitudes, [_microvolts(mean) for mean in mean_voltage]
1340
1341    def _apply_levels(self, name, values):
1342        """Apply new values of waveform, peak_to_peak, fixed_voltage or mean_voltage, for all four channels."""
1343        values = self._normalize(name, values)
1344        levels = {level: list(getattr(self, level))[1:]
1345                  for level in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
1346        levels[name] = values
1347        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], name)
1348        if self._auto_sync:
1349            self._send_levels(*self._device_levels(**levels))
1350        else:
1351            self._unsent = self._unsent | {name}  # Until sync_to_device() sends it (see auto_sync)
1352        return values
1353
1354    def _apply_waveform(self, values):
1355        return self._apply_levels("waveform", values)
1356
1357    def _apply_peak_to_peak(self, values):
1358        return self._apply_levels("peak_to_peak", values)
1359
1360    def _apply_fixed_voltage(self, values):
1361        return self._apply_levels("fixed_voltage", values)
1362
1363    def _apply_mean_voltage(self, values):
1364        return self._apply_levels("mean_voltage", values)
1365
1366    def _send_levels(self, waveforms, amplitudes_uv, means_uv):
1367        """Program the device's waveforms, amplitudes and mean voltages, in an order it accepts at every step.
1368
1369        The device checks each op against what it holds for the other two (see _is_valid_level()), and one
1370        order does not suit every change: a sine wave of 20 V peak to peak cannot become a fixed voltage of
1371        -5 V by either op first. So each channel first takes an amplitude that suits both its current and
1372        its new waveform and mean voltage: its current amplitude if it can, else its new one, else 0 V, which
1373        suits any. Then the waveforms, the mean voltages and the new amplitudes follow. If the device
1374        refuses a step, which means this object's record of what it holds is out of date (e.g. a param sync
1375        edge loaded a stored set), the change is made from 0 V, which works from any state.
1376        """
1377        current = (self._device_waveform, self._device_amplitude_uv, self._device_mean_uv)
1378        if (waveforms, amplitudes_uv, means_uv) == tuple(map(list, current)):
1379            return
1380        first_amplitudes = []
1381        for w0, a0, m0, w1, a1, m1 in zip(*current, waveforms, amplitudes_uv, means_uv):
1382            for candidate in (a0, a1, 0):
1383                if all(_is_valid_level(w, candidate, m) for w, m in ((w0, m0), (w1, m0), (w1, m1))):
1384                    first_amplitudes.append(candidate)
1385                    break
1386        steps = (("A", first_amplitudes), ("W", waveforms), ("M", means_uv), ("A", amplitudes_uv))
1387        try:
1388            self._send_levels_in_order(steps)
1389        except _Rejected:
1390            self._send_levels_in_order((("A", [0] * 4), ("W", waveforms), ("M", means_uv),
1391                                        ("A", amplitudes_uv)), force=True)
1392
1393    def _send_levels_in_order(self, steps, force=False):
1394        """Send ops 'A' (amplitudes, uV), 'W' (waveform names) and 'M' (mean voltages, uV), in order, and keep
1395        the record of what the device holds. Unless `force`, an op that would not change what the record says
1396        the device holds is skipped."""
1397        for op, values in steps:
1398            if op == "W":
1399                if values == self._device_waveform and not force:
1400                    continue
1401                self._write_command(self._OP_SET_WAVEFORM, bytes(WAVEFORMS.index(name) for name in values))
1402                self._read_ack("setting waveform")
1403                self._device_waveform = list(values)
1404            elif op == "A":
1405                if values == self._device_amplitude_uv and not force:
1406                    continue
1407                self._write_command(self._OP_SET_AMPLITUDE, struct.pack("<4i", *values))
1408                self._read_ack("setting the amplitude")
1409                self._device_amplitude_uv = list(values)
1410            else:
1411                if values == self._device_mean_uv and not force:
1412                    continue
1413                self._write_command(self._OP_SET_MEAN_VOLTAGE, struct.pack("<4i", *values))
1414                self._read_ack("setting mean_voltage")
1415                self._device_mean_uv = list(values)
1416
1417    def _apply_resting_voltage(self, values):
1418        # Any resting voltage within -10 V to 10 V goes with any waveform
1419        values = self._normalize("resting_voltage", values)
1420        if self._auto_sync:
1421            self._send_setting("resting_voltage", self._OP_SET_RESTING_VOLTAGE, values)
1422        else:
1423            self._unsent = self._unsent | {"resting_voltage"}
1424        return values
1425
1426    def _apply_play_duration(self, values):
1427        return self._apply_duration("play_duration", self._OP_SET_PLAY_DURATION, values)
1428
1429    def _apply_on_ramp_duration(self, values):
1430        return self._apply_duration("on_ramp_duration", self._OP_SET_ON_RAMP_DURATION, values)
1431
1432    def _apply_off_ramp_duration(self, values):
1433        return self._apply_duration("off_ramp_duration", self._OP_SET_OFF_RAMP_DURATION, values)
1434
1435    def _apply_duration(self, name, op, values):
1436        values = self._normalize(name, values)
1437        if self._auto_sync:
1438            self._send_setting(name, op, values)
1439        else:
1440            self._unsent = self._unsent | {name}
1441        return values
1442
1443    def _send_setting(self, name, op, values):
1444        """Send the resting voltages (int32 microvolts) or a duration (uint32 microseconds), for all four
1445        channels."""
1446        if name == "resting_voltage":
1447            data = struct.pack("<4i", *(_microvolts(v) for v in values))
1448        else:
1449            data = struct.pack("<4I", *(round(d * 1e6) for d in values))
1450        self._write_command(op, data)
1451        self._read_ack(f"setting {name}")
1452
1453    def _apply_trigger_mode(self, values):
1454        names = [to_name(value, TRIGGER_MODES, "trigger mode") for value in values]
1455        if self._auto_sync:
1456            self._write_command(
1457                self._OP_SET_TRIGGER_MODE,
1458                bytes(TRIGGER_MODES.index(name) for name in names),
1459            )
1460            self._read_ack("setting trigger_mode")
1461        else:
1462            self._unsent = self._unsent | {"trigger_mode"}
1463        return names
1464
1465    # One op programs both trigger channels' links, so each list is sent
1466    # with the other's current values
1467    def _apply_trigger_channel1_links(self, values):
1468        links1 = [to_bool(v, "link_trigger_channel1") for v in values]
1469        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
1470        if not self._auto_sync:
1471            self._unsent = self._unsent | {"link_trigger_channel1"}
1472        return links1
1473
1474    def _apply_trigger_channel2_links(self, values):
1475        links2 = [to_bool(v, "link_trigger_channel2") for v in values]
1476        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
1477        if not self._auto_sync:
1478            self._unsent = self._unsent | {"link_trigger_channel2"}
1479        return links2
1480
1481    def _set_trigger_links(self, links1, links2):
1482        """Program both trigger channels' links with one command."""
1483        self._send_trigger_links(links1, links2)
1484        self._link_trigger_channel1._store(links1)
1485        self._link_trigger_channel2._store(links2)
1486
1487    def _send_trigger_links(self, links1, links2):
1488        if self._auto_sync:
1489            self._write_command(self._OP_SET_TRIGGER_LINKS,
1490                                bytes([*map(int, links1), *map(int, links2)]))
1491            self._read_ack("setting the trigger channel links")
1492
1493    def _snapshot(self):
1494        """All settings, and which are unsent, for batch() to restore if its block fails."""
1495        settings = {name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS}
1496        return settings, self._frequency, self._samples_per_cycle, self._unsent
1497
1498    def _restore(self, snapshot):
1499        settings, self._frequency, self._samples_per_cycle, self._unsent = snapshot
1500        for name, values in settings.items():
1501            getattr(self, name)._store(values)
1502
1503    def _unsent_names(self):
1504        """The settings assigned with auto_sync off and not yet sent, for messages, in the class's order."""
1505        return ", ".join(name for name in _SETTING_NAMES if name in self._unsent)
1506
1507    def _check_sync(self):
1508        """Check the whole set before sync_to_device() sends it."""
1509        self._check_levels(self._peak_to_peak[1:], self._mean_voltage[1:], "sync")
1510
1511    def _send_sync(self):
1512        """Send every setting with op 85 ('U'). The device checks the whole set, so no order matters."""
1513        self._unsent = frozenset()  # All are sent below
1514        waveforms, amplitudes, means = self._device_levels(
1515            self._waveform[1:], self._peak_to_peak[1:], self._fixed_voltage[1:], self._mean_voltage[1:])
1516        links = [int(link) for link in (*self._link_trigger_channel1[1:],
1517                                        *self._link_trigger_channel2[1:])]
1518        payload = struct.pack(
1519            self._SETTINGS_FORMAT,
1520            round(self._frequency * 100),
1521            *(WAVEFORMS.index(name) for name in waveforms),
1522            *amplitudes, *means, *(_microvolts(v) for v in self._resting_voltage[1:]),
1523            *(round(d * 1e6) for d in self._play_duration[1:]),
1524            *(round(d * 1e6) for d in self._on_ramp_duration[1:]),
1525            *(round(d * 1e6) for d in self._off_ramp_duration[1:]),
1526            *links,
1527            *(TRIGGER_MODES.index(name) for name in self._trigger_mode[1:]),
1528        )
1529        self._write_command(self._OP_SET_ALL_SETTINGS, payload)
1530        self._read_ack("sync_to_device()")
1531        # Applied now, or, in param sync mode, at the next edge. Either way these are the levels the next
1532        # assignments build on; if a stored set has not loaded yet, _send_levels() finds out and recovers.
1533        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
1534
1535    @staticmethod
1536    def _to_volts(value, name, low, high, note=""):
1537        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
1538                or not math.isfinite(value) or not low <= value <= high:
1539            raise PulsePalError(
1540                f"{name} values must be numbers of volts from {low} to "
1541                f"{high}{note}. Received {value!r}."
1542            )
1543        return float(value)
1544
1545    def _to_seconds(self, value, name, zero_meaning):
1546        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
1547            raise PulsePalError(
1548                f"{name} must be in seconds. Received {value!r}."
1549            )
1550        duration = float(value)
1551        if not math.isfinite(duration) or not \
1552                0 <= duration <= self.info.max_play_duration:
1553            raise PulsePalError(
1554                f"{name} must be {zero_meaning} or a positive number "
1555                f"of seconds up to {self.info.max_play_duration:g}. "
1556                f"Received {value!r}."
1557            )
1558        return duration
1559
1560    # ------------------------------------------------------------------
1561    # Internals: serial port
1562    # ------------------------------------------------------------------
1563
1564    def _write_command(self, op_code, data=b""):
1565        """Send one command, with its framing byte, in a single write.
1566
1567        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
1568        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it would put every
1569        reply after it out of step. So it is discarded first (_discard_lost_reply()), and this command's reply, if it
1570        has one, is pending until _read_raw() has read it, as in PulsePalDevice.
1571        """
1572        if self._reply_pending is not None:
1573            self._discard_lost_reply()
1574        if op_code not in _OPS_WITHOUT_TRACKED_REPLY:
1575            self._reply_pending = op_code  # Before the write: a Ctrl-C can come as soon as it returns
1576        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1577        bytes_written = self.port.write(message)
1578        if bytes_written != len(message):
1579            raise PulsePalError(
1580                f"Wrote {bytes_written} byte(s), expected to write "
1581                f"{len(message)} byte(s)."
1582            )
1583
1584    def _discard_lost_reply(self):
1585        """Discard what arrives of a reply that was not read in full (see _write_command()), and bring this object
1586        back in step with the device.
1587
1588        The command may have changed settings, unknown to this object: an assignment interrupted after its write,
1589        for one, which the device took and this object did not. So after one of _SETTING_OPS, they are read back
1590        (op 'R'), outside param sync mode, where op 'R' returns the settings playing rather than a set stored for the
1591        next sync edge.
1592        """
1593        op = self._reply_pending
1594        self._reply_pending = None
1595        n_late = len(_common.read_until_quiet(self.port))
1596        read_back = op in self._SETTING_OPS and "Param Sync" not in list(self._trigger_mode)[1:]
1597        _common.warn_lost_reply(f"op {chr(op)!r}", n_late,
1598                                ", and the settings were read back from the device" if read_back else "")
1599        if read_back:
1600            try:
1601                self.sync_from_device()
1602            except PulsePalError as exc:
1603                raise PulsePalError(
1604                    f"Synth Pal did not send its settings, read back after the reply to an earlier command was lost. "
1605                    f"{_common.NO_REPLY_HINT}"
1606                ) from exc
1607
1608    def _read_raw(self, n_bytes):
1609        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
1610        longer pending (see _write_command()). Only connecting reads a reply in more than one call."""
1611        message = self.port.read(n_bytes)
1612        if len(message) < n_bytes:
1613            raise PulsePalError(
1614                f"Synth Pal sent {len(message)} of the {n_bytes} byte(s) of its reply within "
1615                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
1616            )
1617        self._reply_pending = None
1618        return message
1619
1620    def _read_ack(self, context):
1621        """Read a one-byte confirmation: 1 if the device executed the
1622        command, 0 if it rejected it."""
1623        try:
1624            reply = self._read_raw(1)[0]
1625        except PulsePalError as exc:
1626            raise PulsePalError(
1627                f"Synth Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
1628                f"{_common.NO_REPLY_HINT}"
1629            ) from exc
1630        if reply != 1:
1631            raise _Rejected(
1632                f"Synth Pal rejected {context}. A value was out of range."
1633            )

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

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

from pulsepal import SynthPalDevice

S = SynthPalDevice("COM3")
S.waveform[1] = "Triangle"
S.trigger(1)
S.close()

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

Closing the connection stops playback, as it does on Pulse Pal (each channel over its off ramp), and puts the device's own name back on its screen. The device keeps its settings, so TTL triggers still play the channels.

SynthPalDevice(port_name=None, *, serial_number=None, timeout=10)
362    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
363        """Open a connection to a Synth Pal.
364
365        Args:
366            port_name: USB serial port of the device, such as `COM3` on
367                Windows or `/dev/ttyACM0` on Linux.
368            serial_number: Instead of `port_name`, the device's USB serial
369                number (`DeviceInfo.serial_number`), which finds its port
370                wherever it is plugged in.
371            timeout: Serial read timeout, in seconds.
372
373        Firmware newer than this module knows is used with a warning: new
374        firmware only adds commands. Update the package to use what is new.
375
376        Raises:
377            PulsePalError: If the device does not reply to the handshake, or
378                runs other firmware.
379            serial.SerialException: If the serial port cannot be opened,
380                e.g. because another program has it open (on Linux and
381                macOS, another session of these classes, which lock it).
382        """
383        self._closed = True
384        self._auto_sync = True
385        self.info = DeviceInfo()
386        self._frequency = None
387        self._samples_per_cycle = None
388        # What the device holds for each output channel's waveform, amplitude (in microvolts: a periodic
389        # waveform's peak to peak voltage, or a fixed voltage) and mean voltage (in microvolts), as far as this
390        # object has programmed them. _send_levels() changes them in an order the device accepts.
391        self._device_waveform = ["Sine"] * 4
392        self._device_amplitude_uv = [5_000_000] * 4
393        self._device_mean_uv = [0] * 4
394        self._waveform = ChannelSettings(
395            "waveform", ["Sine"] * 4, self, "_apply_waveform")
396        self._peak_to_peak = ChannelSettings(
397            "peak_to_peak", [5.0] * 4, self, "_apply_peak_to_peak")
398        self._fixed_voltage = ChannelSettings(
399            "fixed_voltage", [5.0] * 4, self, "_apply_fixed_voltage")
400        self._mean_voltage = ChannelSettings(
401            "mean_voltage", [0.0] * 4, self, "_apply_mean_voltage")
402        self._resting_voltage = ChannelSettings(
403            "resting_voltage", [0.0] * 4, self, "_apply_resting_voltage")
404        self._play_duration = ChannelSettings(
405            "play_duration", [1.0] * 4, self, "_apply_play_duration")
406        self._on_ramp_duration = ChannelSettings(
407            "on_ramp_duration", [0.0] * 4, self, "_apply_on_ramp_duration")
408        self._off_ramp_duration = ChannelSettings(
409            "off_ramp_duration", [0.0] * 4, self, "_apply_off_ramp_duration")
410        self._trigger_mode = ChannelSettings(
411            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
412        self._link_trigger_channel1 = ChannelSettings(
413            "link_trigger_channel1", [False] * 4, self,
414            "_apply_trigger_channel1_links")
415        self._link_trigger_channel2 = ChannelSettings(
416            "link_trigger_channel2", [False] * 4, self,
417            "_apply_trigger_channel2_links")
418
419        port_name = _common.port_name_from(port_name, serial_number, "SynthPalDevice")
420        self.port = serial.Serial(
421            port_name,
422            self._BAUD_RATE,
423            timeout=timeout,
424            rtscts=True,
425            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
426            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
427            # it, such as another of these classes, gets an error instead
428            exclusive=True,
429        )
430        self._closed = False
431        try:
432            # Discard anything left in the buffer by an earlier session
433            self.port.reset_input_buffer()
434            self._handshake()
435            self._read_hardware_info()
436            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
437            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
438            self.stop()
439            self.set_default_params()
440        except BaseException:
441            # Op 81 means something else to other devices, so it is sent
442            # only once the device has identified itself as a Synth Pal
443            self._close(send_disconnect=self.info.firmware_version is not None)
444            raise

Open a connection to a Synth Pal.

Arguments:
  • port_name: USB serial port of the 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 wherever it is plugged in.
  • timeout: Serial read timeout, in seconds.

Firmware newer than this module knows is used with a warning: new firmware only adds commands. Update the package to use what is new.

Raises:
  • PulsePalError: If the device does not reply to the handshake, or runs other firmware.
  • 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.

Properties of the connected device. See DeviceInfo.

@staticmethod
def serialportlist(ports_to_list='available'):
446    @staticmethod
447    def serialportlist(ports_to_list="available"):
448        """Return the names of the USB serial ports on this computer.
449
450        Called on the class, without connecting to a device, to find the
451        port name to pass to `SynthPalDevice`.
452
453        Args:
454            ports_to_list: `available` to list only the ports that are
455                not already in use, or `all` to list every USB serial
456                port. Not case sensitive.
457
458        Returns:
459            Sorted list of port names, such as `["COM3", "COM7"]`.
460
461        Raises:
462            PulsePalError: If `ports_to_list` is not `available` or `all`.
463        """
464        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 SynthPalDevice.

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

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

Raises:
  • PulsePalError: If ports_to_list is not available or all.
def set_default_params(self):
470    def set_default_params(self):
471        """Program the default settings on the device.
472
473        The defaults are a frequency of 100 Hz, and on every output
474        channel a sine wave of 5 V peak to peak around a mean voltage of
475        0 V, resting at 0 V, played for 1 second with no ramps, and a
476        fixed voltage of 5 V for when the waveform is "Fixed Voltage".
477        Both trigger channels are in normal mode, and no output channel is
478        linked to a trigger channel, so a TTL starts nothing until a script
479        links it. They match the settings the device starts with. They are sent at once,
480        also while `SynthPalDevice.auto_sync` is off.
481        """
482        auto_sync = self._auto_sync
483        self._auto_sync = True
484        try:
485            self.frequency = 100
486            # In this order, each is valid whatever the device holds: a mean of 0 V goes with any
487            # amplitude, 5 V is then a valid amplitude for any waveform, and a sine wave is then valid
488            self._send_levels_in_order((("M", [0] * 4), ("A", [5_000_000] * 4), ("W", ["Sine"] * 4)),
489                                       force=True)
490            for settings, value in ((self._waveform, "Sine"), (self._peak_to_peak, 5.0),
491                                    (self._fixed_voltage, 5.0), (self._mean_voltage, 0.0)):
492                settings._store([value] * 4)
493            self.resting_voltage = [0] * 4
494            self.play_duration = [1] * 4
495            self.on_ramp_duration = [0] * 4
496            self.off_ramp_duration = [0] * 4
497            self.trigger_mode = ["Normal"] * 2
498            self._set_trigger_links([False] * 4, [False] * 4)
499            self._unsent = frozenset()  # Every setting has been sent
500        finally:
501            self._auto_sync = auto_sync

Program the default settings on the device.

The defaults are a frequency of 100 Hz, and on every output channel a sine wave of 5 V peak to peak around a mean voltage of 0 V, resting at 0 V, played for 1 second with no ramps, and a fixed voltage of 5 V for when the waveform is "Fixed Voltage". Both trigger channels are in normal mode, and no output channel is linked to a trigger channel, so a TTL starts nothing until a script links it. They match the settings the device starts with. They are sent at once, also while SynthPalDevice.auto_sync is off.

frequency
503    @property
504    def frequency(self):
505        """Frequency of all output channels, in Hz.
506
507        1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during
508        playback: playing channels carry on from the same point in their
509        cycle, at the new frequency, and keep the time they have left to
510        play. The frequency played is exact: the device's sample clock is a
511        whole multiple of it (see `SynthPalDevice.sampling_rate`). For
512        `"White Noise"` it sets only the sampling rate.
513        """
514        return self._frequency

Frequency of all output channels, in Hz.

1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during playback: playing channels carry on from the same point in their cycle, at the new frequency, and keep the time they have left to play. The frequency played is exact: the device's sample clock is a whole multiple of it (see SynthPalDevice.sampling_rate). For "White Noise" it sets only the sampling rate.

auto_sync
544    @property
545    def auto_sync(self):
546        """Whether assigning a setting programs the device at once.
547
548        `True` (the default): each assignment, such as
549        `S.peak_to_peak[1] = 2`, programs the device at once, as the
550        device's own op for that setting does, also while a trigger
551        channel is in param sync mode.
552
553        `False`: assignments change only this object's copy of the settings,
554        checking each value; `SynthPalDevice.sync_to_device` then sends all
555        of them at once. Use it to store the next trial's settings for a
556        param sync edge (see `SynthPalDevice.trigger_mode`), or to change
557        several settings in one command. Turning it back on does not send
558        changes made meanwhile, so it raises `pulsepal.PulsePalError` while
559        there are any: call `SynthPalDevice.sync_to_device` first to send
560        them, or `SynthPalDevice.sync_from_device` to discard them. A
561        `SynthPalDevice.batch` block does it all for you. `close()` warns of
562        changes never sent. `trigger`, `stop`, `status` and
563        `set_default_params` act at once either way.
564        """
565        return self._auto_sync

Whether assigning a setting programs the device at once.

True (the default): each assignment, such as S.peak_to_peak[1] = 2, programs the device at once, as the device's own op for that setting does, also while a trigger channel is in param sync mode.

False: assignments change only this object's copy of the settings, checking each value; SynthPalDevice.sync_to_device then sends all of them at once. Use it to store the next trial's settings for a param sync edge (see SynthPalDevice.trigger_mode), or to change several settings in one command. Turning it back on does not send changes made meanwhile, so it raises pulsepal.PulsePalError while there are any: call SynthPalDevice.sync_to_device first to send them, or SynthPalDevice.sync_from_device to discard them. A SynthPalDevice.batch block does it all for you. close() warns of changes never sent. trigger, stop, status and set_default_params act at once either way.

def batch(self):
578    def batch(self):
579        """Change several settings, and send them in one command.
580
581        Returns a context manager. In its block, assignments change only
582        this object's copy of the settings (as with
583        `SynthPalDevice.auto_sync` off), and when the block ends,
584        `SynthPalDevice.sync_to_device` sends all of them at once. If the
585        block raises an error, nothing is sent, and the settings go back
586        to what they were.
587
588        ```python
589        with S.batch():
590            S.frequency = 880
591            S.waveform = ["Triangle"] * 4
592            S.peak_to_peak = [2, 4, 6, 8]
593        ```
594
595        In param sync mode, the device stores the settings for the next
596        rising edge on the param sync channel (see
597        `SynthPalDevice.trigger_mode`).
598        """
599        return _common.batch(self)

Change several settings, and send them in one command.

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

with S.batch():
    S.frequency = 880
    S.waveform = ["Triangle"] * 4
    S.peak_to_peak = [2, 4, 6, 8]

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

def sync_from_device(self):
601    def sync_from_device(self):
602        """Read every setting from the device into this object.
603
604        Use it after settings were changed with the device's joystick, for
605        example. The device holds one amplitude per channel: a Fixed
606        Voltage channel's goes to `SynthPalDevice.fixed_voltage`, and any
607        other channel's to `SynthPalDevice.peak_to_peak`. This object keeps
608        the other one, reduced if need be to suit the mean voltage read
609        back. In param sync mode, it reads the settings the device plays
610        now, not a set waiting for the next edge. Changes made with
611        `SynthPalDevice.auto_sync` off and not yet sent are discarded.
612
613        Raises:
614            PulsePalError: If the device does not reply.
615        """
616        self._write_command(self._OP_GET_ALL_SETTINGS)
617        values = struct.unpack(self._SETTINGS_FORMAT, self._read_raw(struct.calcsize(self._SETTINGS_FORMAT)))
618        centihz = values[0]
619        waveforms = [WAVEFORMS[code] for code in values[1:5]]
620        amplitudes, means = list(values[5:9]), list(values[9:13])
621        peak_to_peak, fixed_voltage = list(self._peak_to_peak)[1:], list(self._fixed_voltage)[1:]
622        for i, (name, amplitude, mean) in enumerate(zip(waveforms, amplitudes, means)):
623            if name == FIXED_VOLTAGE:
624                fixed_voltage[i] = amplitude / 1e6
625                # This object's own peak to peak voltage must still suit the mean voltage (see _check_levels())
626                peak_to_peak[i] = min(peak_to_peak[i], (2 * _MAX_VOLTAGE_UV - 2 * abs(mean)) / 1e6)
627            else:
628                peak_to_peak[i] = amplitude / 1e6
629        self._frequency = centihz / 100
630        self._samples_per_cycle = _samples_per_cycle(centihz)
631        self._waveform._store(waveforms)
632        self._peak_to_peak._store(peak_to_peak)
633        self._fixed_voltage._store(fixed_voltage)
634        self._mean_voltage._store([mean / 1e6 for mean in means])
635        self._resting_voltage._store([rest / 1e6 for rest in values[13:17]])
636        self._play_duration._store([d / 1e6 for d in values[17:21]])
637        self._on_ramp_duration._store([d / 1e6 for d in values[21:25]])
638        self._off_ramp_duration._store([d / 1e6 for d in values[25:29]])
639        self._link_trigger_channel1._store([bool(link) for link in values[29:33]])
640        self._link_trigger_channel2._store([bool(link) for link in values[33:37]])
641        self._trigger_mode._store([TRIGGER_MODES[code] for code in values[37:39]])
642        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
643        self._unsent = frozenset()  # Every setting is read

Read every setting from the device into this object.

Use it after settings were changed with the device's joystick, for example. The device holds one amplitude per channel: a Fixed Voltage channel's goes to SynthPalDevice.fixed_voltage, and any other channel's to SynthPalDevice.peak_to_peak. This object keeps the other one, reduced if need be to suit the mean voltage read back. In param sync mode, it reads the settings the device plays now, not a set waiting for the next edge. Changes made with SynthPalDevice.auto_sync off and not yet sent are discarded.

Raises:
  • PulsePalError: If the device does not reply.
def export_params(self):
645    def export_params(self):
646        """Return every setting, as a dict of plain values and lists.
647
648        Keyed by setting name: `"frequency"`, then the channel settings,
649        each with one value per channel and no unused index 0. It holds
650        only numbers, booleans and names, so it can be saved with `json`
651        and logged with your data, to record exactly what the device
652        played.
653
654        ```python
655        import json
656        with open("trial_settings.json", "w") as f:
657            json.dump(S.export_params(), f)
658        ```
659        """
660        params = {"frequency": self._frequency}
661        params.update({name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS})
662        return params

Return every setting, as a dict of plain values and lists.

Keyed by setting name: "frequency", then the channel settings, each with one value per channel and no unused index 0. 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.

import json
with open("trial_settings.json", "w") as f:
    json.dump(S.export_params(), f)
def import_params(self, params):
664    def import_params(self, params):
665        """Program the device with settings exported by `SynthPalDevice.export_params`.
666
667        All of them are checked first, and then sent in one command, as
668        at the end of a `SynthPalDevice.batch` block: in param sync mode,
669        the device stores them for the next sync edge. The levels
670        (`waveform`, `peak_to_peak`, `fixed_voltage`, `mean_voltage`) are
671        checked together, so their order in `params` does not matter.
672        Settings missing from `params` keep their values. A name that is
673        not a setting is skipped with a warning, so that settings exported
674        by a newer version of this package, which may have more, can still
675        be imported.
676
677        ```python
678        import json
679        with open("trial_settings.json") as f:
680            S.import_params(json.load(f))
681        ```
682
683        Args:
684            params: A dict of setting name to its value: a number for
685                `"frequency"`, and one value per channel (a list, tuple or
686                NumPy array) for the others, as `export_params` returns.
687
688        Raises:
689            PulsePalError: If a value is invalid. Nothing is sent, and the
690                settings keep the values they had.
691        """
692        names = {"frequency", *_CHANNEL_SETTINGS}
693        unknown = [name for name in params if name not in names]
694        if unknown:
695            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
696                          "SynthPalDevice does not have.", stacklevel=2)
697        levels = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")
698        with self.batch():
699            for name, values in params.items():
700                if name in levels:
701                    # Each is checked alone here, and the levels together when the block ends (_check_sync()):
702                    # an assignment would check each against the others' old values
703                    getattr(self, name)._store(
704                        self._normalize(name, _common.one_value_per_channel(name, values, 4)))
705                elif name in names:
706                    setattr(self, name, values)

Program the device with settings exported by SynthPalDevice.export_params.

All of them are checked first, and then sent in one command, as at the end of a SynthPalDevice.batch block: in param sync mode, the device stores them for the next sync edge. The levels (waveform, peak_to_peak, fixed_voltage, mean_voltage) are checked together, so their order in params does not matter. Settings missing from params keep their values. A name that is not a setting is skipped with a warning, so that settings exported by a newer version of this package, which may have more, can still be imported.

import json
with open("trial_settings.json") as f:
    S.import_params(json.load(f))
Arguments:
  • params: A dict of setting name to its value: a number for "frequency", and one value per channel (a list, tuple or NumPy array) for the others, as export_params returns.
Raises:
  • PulsePalError: If a value is invalid. Nothing is sent, and the settings keep the values they had.
def sync_to_device(self):
708    def sync_to_device(self):
709        """Send every setting to the device in one command.
710
711        While a trigger channel is in param sync mode, the device stores
712        the set, replacing any set stored before, and that channel's next
713        rising edge loads it (see `SynthPalDevice.trigger_mode`). Otherwise
714        the device applies it at once. The settings sent are this object's:
715        `frequency`, and for each channel `waveform`, `peak_to_peak` or
716        `fixed_voltage`, `mean_voltage`, `resting_voltage`,
717        `play_duration`, `on_ramp_duration`, `off_ramp_duration`,
718        `link_trigger_channel1` and `link_trigger_channel2`, and
719        `trigger_mode`. They read as sent, including while a set waits for
720        its edge.
721
722        Raises:
723            PulsePalError: If the device rejects the set.
724        """
725        self._check_sync()
726        self._send_sync()

Send every setting to the device in one command.

While a trigger channel is in param sync mode, the device stores the set, replacing any set stored before, and that channel's next rising edge loads it (see SynthPalDevice.trigger_mode). Otherwise the device applies it at once. The settings sent are this object's: frequency, and for each channel waveform, peak_to_peak or fixed_voltage, mean_voltage, resting_voltage, play_duration, on_ramp_duration, off_ramp_duration, link_trigger_channel1 and link_trigger_channel2, and trigger_mode. They read as sent, including while a set waits for its edge.

Raises:
  • PulsePalError: If the device rejects the set.
def configure( self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None, mean_voltage=None, resting_voltage=None, play_duration=None, on_ramp_duration=None, off_ramp_duration=None):
728    def configure(self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None,
729                  mean_voltage=None, resting_voltage=None, play_duration=None,
730                  on_ramp_duration=None, off_ramp_duration=None):
731        """Set several of the output channels' settings at once.
732
733        Settings left out keep their values. The new settings are checked
734        together, so their order does not matter: for example, a channel
735        whose sine wave swings 2 V around a mean voltage of 9 V can go
736        straight to 20 V peak to peak around 0 V. With
737        `SynthPalDevice.auto_sync` on, the class programs the device in an
738        order it accepts at every step.
739
740        ```python
741        S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
742        S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
743                    play_duration=0.1)
744        ```
745
746        Args:
747            channels: Output channel number 1-4, or several as a list,
748                tuple or NumPy array.
749            waveform, peak_to_peak, fixed_voltage, mean_voltage,
750                resting_voltage, play_duration, on_ramp_duration,
751                off_ramp_duration: New values, as for the settings of the
752                same names: one value for every channel given, or a list
753                with one value per channel given, in the same order.
754
755        Raises:
756            PulsePalError: If a value is out of range, or the settings do
757                not go together (nothing is sent), or the device rejects
758                them.
759        """
760        channels = _common.channel_numbers(channels)
761        if len(set(channels)) != len(channels):
762            raise PulsePalError(f"configure(): channels must be distinct. Received {channels!r}.")
763        requested = {"waveform": waveform, "peak_to_peak": peak_to_peak, "fixed_voltage": fixed_voltage,
764                     "mean_voltage": mean_voltage, "resting_voltage": resting_voltage,
765                     "play_duration": play_duration, "on_ramp_duration": on_ramp_duration,
766                     "off_ramp_duration": off_ramp_duration}
767        new_values = {}
768        for name, value in requested.items():
769            if value is None:
770                continue
771            values = list(value) if isinstance(value, (list, tuple, np.ndarray)) else [value]
772            if len(values) == 1:
773                values = values * len(channels)
774            if len(values) != len(channels):
775                raise PulsePalError(
776                    f"configure(): {name} has {len(values)} values for {len(channels)} channels. Give one "
777                    "value, or one value per channel."
778                )
779            settings = list(getattr(self, name))[1:]
780            for channel, item in zip(channels, values):
781                settings[channel - 1] = item
782            new_values[name] = self._normalize(name, settings)
783        levels = {name: new_values.get(name, list(getattr(self, name))[1:])
784                  for name in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
785        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], "configure")
786        if self._auto_sync:
787            self._send_levels(*self._device_levels(**levels))
788        else:
789            self._unsent = self._unsent | set(new_values)  # Until sync_to_device() sends them (see auto_sync)
790        for name in levels:
791            if name in new_values:
792                getattr(self, name)._store(new_values[name])
793        ops = {"resting_voltage": self._OP_SET_RESTING_VOLTAGE, "play_duration": self._OP_SET_PLAY_DURATION,
794               "on_ramp_duration": self._OP_SET_ON_RAMP_DURATION,
795               "off_ramp_duration": self._OP_SET_OFF_RAMP_DURATION}
796        for name, op in ops.items():
797            if name in new_values:
798                if self._auto_sync:
799                    self._send_setting(name, op, new_values[name])
800                getattr(self, name)._store(new_values[name])

Set several of the output channels' settings at once.

Settings left out keep their values. The new settings are checked together, so their order does not matter: for example, a channel whose sine wave swings 2 V around a mean voltage of 9 V can go straight to 20 V peak to peak around 0 V. With SynthPalDevice.auto_sync on, the class programs the device in an order it accepts at every step.

S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
            play_duration=0.1)
Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array.
  • waveform, peak_to_peak, fixed_voltage, mean_voltage, resting_voltage, play_duration, on_ramp_duration, off_ramp_duration: New values, as for the settings of the same names: one value for every channel given, or a list with one value per channel given, in the same order.
Raises:
  • PulsePalError: If a value is out of range, or the settings do not go together (nothing is sent), or the device rejects them.
samples_per_cycle
802    @property
803    def samples_per_cycle(self):
804        """Samples in one cycle of the waveform, at `frequency`.
805
806        The largest multiple of 4 whose sampling rate is at most
807        `DeviceInfo.max_sampling_rate` (100 kHz), so that a sample falls
808        on every edge, peak and trough of every waveform. For example,
809        332 at 300 Hz and 4 at 20 kHz.
810        """
811        return self._samples_per_cycle

Samples in one cycle of the waveform, at frequency.

The largest multiple of 4 whose sampling rate is at most DeviceInfo.max_sampling_rate (100 kHz), so that a sample falls on every edge, peak and trough of every waveform. For example, 332 at 300 Hz and 4 at 20 kHz.

sampling_rate
813    @property
814    def sampling_rate(self):
815        """The rate at which the device plays samples, in Hz.
816
817        `samples_per_cycle` times `frequency`: 100 kHz or just below it,
818        and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at
819        300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide
820        every rate exactly, so sample periods differ by up to one tick of
821        its 24 MHz clock (42 ns), arranged so that the frequency is exact.
822        """
823        return self._samples_per_cycle * self._frequency

The rate at which the device plays samples, in Hz.

samples_per_cycle times frequency: 100 kHz or just below it, and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at 300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide every rate exactly, so sample periods differ by up to one tick of its 24 MHz clock (42 ns), arranged so that the frequency is exact.

waveform
825    @property
826    def waveform(self):
827        """The waveform of each output channel.
828
829        Indexed by channel number (see "Channel settings" above). Each
830        cycle starts at the trigger:
831
832        - `"Sine"` and `"Triangle"`: start at the mean voltage, rising.
833        - `"Square"`: high for the first half of each cycle, then low.
834        - `"Sawtooth"`: rises from its lowest voltage to its highest, then
835          falls back at the end of the cycle.
836        - `"Fixed Voltage"`: steps to the channel's
837          `SynthPalDevice.fixed_voltage` for the play duration, and
838          returns to the resting voltage. It is not periodic, so the
839          frequency does not change it.
840        - `"White Noise"`: a new random voltage on every sample, uniform
841          over the peak to peak voltage around the mean voltage. It is
842          rendered at the sampling rate the frequency sets
843          (`SynthPalDevice.sampling_rate`), so no claim is made about its
844          spectrum. Each channel has its own random sequence, which carries
845          on from one playback to the next: every playback is new noise.
846          The sequences start from the same point when the device powers
847          up.
848
849        The periodic waveforms and white noise swing
850        `SynthPalDevice.peak_to_peak` around `SynthPalDevice.mean_voltage`.
851        Names are not case sensitive. A change applies to playback in
852        progress.
853        """
854        return self._waveform

The waveform of each output channel.

Indexed by channel number (see "Channel settings" above). Each cycle starts at the trigger:

  • "Sine" and "Triangle": start at the mean voltage, rising.
  • "Square": high for the first half of each cycle, then low.
  • "Sawtooth": rises from its lowest voltage to its highest, then falls back at the end of the cycle.
  • "Fixed Voltage": steps to the channel's SynthPalDevice.fixed_voltage for the play duration, and returns to the resting voltage. It is not periodic, so the frequency does not change it.
  • "White Noise": a new random voltage on every sample, uniform over the peak to peak voltage around the mean voltage. It is rendered at the sampling rate the frequency sets (SynthPalDevice.sampling_rate), so no claim is made about its spectrum. Each channel has its own random sequence, which carries on from one playback to the next: every playback is new noise. The sequences start from the same point when the device powers up.

The periodic waveforms and white noise swing SynthPalDevice.peak_to_peak around SynthPalDevice.mean_voltage. Names are not case sensitive. A change applies to playback in progress.

peak_to_peak
860    @property
861    def peak_to_peak(self):
862        """The peak to peak voltage of each output channel's waveform, in volts.
863
864        Indexed by channel number (see "Channel settings" above). 0 to
865        20 V: the waveform swings `peak_to_peak / 2` above and below the
866        channel's `SynthPalDevice.mean_voltage`, so it must stay within
867        -10 V to 10 V: `abs(mean_voltage) + peak_to_peak / 2 <= 10`. To
868        raise it beyond what the mean voltage allows, change the mean
869        voltage first, or set both with `SynthPalDevice.configure`.
870
871        A sine wave of 4 V peak to peak swings from -2 V to 2 V around a
872        mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak
873        to peak is uniform from -2 V to 2 V. A `"Fixed Voltage"` channel
874        keeps its peak to peak voltage for when it plays a periodic waveform
875        again.
876
877        Set to the nearest microvolt. A change applies to playback in
878        progress.
879        """
880        return self._peak_to_peak

The peak to peak voltage of each output channel's waveform, in volts.

Indexed by channel number (see "Channel settings" above). 0 to 20 V: the waveform swings peak_to_peak / 2 above and below the channel's SynthPalDevice.mean_voltage, so it must stay within -10 V to 10 V: abs(mean_voltage) + peak_to_peak / 2 <= 10. To raise it beyond what the mean voltage allows, change the mean voltage first, or set both with SynthPalDevice.configure.

A sine wave of 4 V peak to peak swings from -2 V to 2 V around a mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak to peak is uniform from -2 V to 2 V. A "Fixed Voltage" channel keeps its peak to peak voltage for when it plays a periodic waveform again.

Set to the nearest microvolt. A change applies to playback in progress.

fixed_voltage
886    @property
887    def fixed_voltage(self):
888        """The voltage each output channel steps to when its waveform is
889        `"Fixed Voltage"`, in volts.
890
891        Indexed by channel number (see "Channel settings" above). -10 to
892        10 V, with any resting voltage and mean voltage. A channel playing
893        another waveform keeps its fixed voltage for when its waveform is
894        `"Fixed Voltage"` again.
895
896        Set to the nearest microvolt. A change applies to playback in
897        progress.
898        """
899        return self._fixed_voltage

The voltage each output channel steps to when its waveform is "Fixed Voltage", in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V, with any resting voltage and mean voltage. A channel playing another waveform keeps its fixed voltage for when its waveform is "Fixed Voltage" again.

Set to the nearest microvolt. A change applies to playback in progress.

mean_voltage
905    @property
906    def mean_voltage(self):
907        """The mean voltage of each output channel's waveform, in volts.
908
909        Indexed by channel number (see "Channel settings" above). -10 to
910        10 V. A periodic waveform, or white noise, swings around it at full
911        amplitude, and must stay within -10 V to 10 V with the
912        `peak_to_peak` voltage: `abs(mean_voltage) + peak_to_peak / 2 <= 10`.
913        It may differ from the `resting_voltage`, which the channel
914        outputs between playbacks: the ramps move the mean between the two
915        (see "Ramps" above). A `"Fixed Voltage"` ignores it. Set to the
916        nearest microvolt. A change applies to playback in progress.
917        """
918        return self._mean_voltage

The mean voltage of each output channel's waveform, in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V. A periodic waveform, or white noise, swings around it at full amplitude, and must stay within -10 V to 10 V with the peak_to_peak voltage: abs(mean_voltage) + peak_to_peak / 2 <= 10. It may differ from the resting_voltage, which the channel outputs between playbacks: the ramps move the mean between the two (see "Ramps" above). A "Fixed Voltage" ignores it. Set to the nearest microvolt. A change applies to playback in progress.

resting_voltage
924    @property
925    def resting_voltage(self):
926        """The resting voltage of each output channel, in volts.
927
928        Indexed by channel number (see "Channel settings" above). -10 to
929        10 V, with any waveform. The channel outputs it while idle, and
930        its ramps start and end there (see "Ramps" above). Set to the
931        nearest microvolt. A change applies to playback in progress.
932        """
933        return self._resting_voltage

The resting voltage of each output channel, in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V, with any waveform. The channel outputs it while idle, and its ramps start and end there (see "Ramps" above). Set to the nearest microvolt. A change applies to playback in progress.

play_duration
939    @property
940    def play_duration(self):
941        """How long each output channel plays after a trigger, in seconds.
942
943        Indexed by channel number (see "Channel settings" above). Up to
944        `DeviceInfo.max_play_duration` (3600 s). `0` plays until the
945        channel is stopped. It counts at full amplitude, after the on
946        ramp. The channel stops when the duration has elapsed, part way
947        through a cycle if need be, and returns to its resting voltage,
948        over its off ramp. Durations are counted in samples, rounded to
949        the nearest one, and a nonzero duration lasts at least one sample.
950        A change applies to playback in progress.
951        """
952        return self._play_duration

How long each output channel plays after a trigger, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s). 0 plays until the channel is stopped. It counts at full amplitude, after the on ramp. The channel stops when the duration has elapsed, part way through a cycle if need be, and returns to its resting voltage, over its off ramp. Durations are counted in samples, rounded to the nearest one, and a nonzero duration lasts at least one sample. A change applies to playback in progress.

on_ramp_duration
958    @property
959    def on_ramp_duration(self):
960        """How long each output channel fades in after a trigger, in seconds.
961
962        Indexed by channel number (see "Channel settings" above). Up to
963        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
964        ramp. Over it, the peak to peak voltage rises linearly from 0 V to
965        `peak_to_peak`, and the mean from the `resting_voltage` to the
966        `mean_voltage`, before the play duration (see "Ramps" above).
967        Counted in samples, as `play_duration` is. A change applies to
968        playback in progress: a ramp under way keeps the level it has
969        reached, and goes on at the new rate.
970        """
971        return self._on_ramp_duration

How long each output channel fades in after a trigger, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s); 0 (the default) for no ramp. Over it, the peak to peak voltage rises linearly from 0 V to peak_to_peak, and the mean from the resting_voltage to the mean_voltage, before the play duration (see "Ramps" above). Counted in samples, as play_duration is. A change applies to playback in progress: a ramp under way keeps the level it has reached, and goes on at the new rate.

off_ramp_duration
977    @property
978    def off_ramp_duration(self):
979        """How long each output channel fades out when it stops, in seconds.
980
981        Indexed by channel number (see "Channel settings" above). Up to
982        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
983        ramp. Over it, the peak to peak voltage falls linearly to 0 V, and
984        the mean to the `resting_voltage`, after the play duration or a
985        stop (see "Ramps" above). Counted in samples, as `play_duration`
986        is. A change applies to playback in progress.
987        """
988        return self._off_ramp_duration

How long each output channel fades out when it stops, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s); 0 (the default) for no ramp. Over it, the peak to peak voltage falls linearly to 0 V, and the mean to the resting_voltage, after the play duration or a stop (see "Ramps" above). Counted in samples, as play_duration is. A change applies to playback in progress.

trigger_mode
 994    @property
 995    def trigger_mode(self):
 996        """How each trigger channel acts on the output channels linked to it.
 997
 998        Indexed by trigger channel number, 1 or 2 (index 0 is unused).
 999        These are Pulse Pal's trigger modes:
1000
1001        - `"Normal"`: a rising edge starts the linked channels. Channels
1002          that are playing ignore it.
1003        - `"Toggle"`: a rising edge starts the linked channels, or stops
1004          those that are playing.
1005        - `"Gated"`: a rising edge starts the linked channels, and a
1006          falling edge stops them, unless the other trigger channel is
1007          also gated, linked to them, and still high. With a play
1008          duration of 0, a channel plays for exactly as long as the TTL is
1009          high.
1010        - `"Param Sync"`: a rising edge starts and stops nothing (the
1011          channel's links are ignored). It loads the settings most recently
1012          sent by `SynthPalDevice.sync_to_device` (or at the end of a
1013          `SynthPalDevice.batch` block), if any. This is how the next
1014          trial's settings are sent during the current trial and applied
1015          the instant it starts.
1016
1017        At a param sync edge, the frequency and both trigger modes change
1018        at once: they are shared by all channels. An output channel at its
1019        resting voltage takes its new settings at once. One that is playing
1020        (off ramp included) finishes on the settings it started with, at
1021        the new frequency, and takes the new ones the moment it reaches its
1022        resting voltage, so the next trigger plays them. A trigger mode
1023        sent by `SynthPalDevice.sync_to_device` applies from the next
1024        edge.
1025
1026        Only `SynthPalDevice.sync_to_device` is held back for the edge:
1027        with `SynthPalDevice.auto_sync` on, every assignment programs the
1028        device at once, also in param sync mode. So leaving param sync mode
1029        means assigning `trigger_mode` with `auto_sync` on. When no trigger
1030        channel is left in the mode, the device discards a stored set.
1031
1032        To start channels on the same edge, wire the TTL to the other
1033        trigger channel too: the settings load first, so the channels it
1034        starts play the new settings. Connecting, and `set_default_params`,
1035        take both trigger channels out of param sync mode.
1036
1037        Names are not case sensitive.
1038        """
1039        return self._trigger_mode

How each trigger channel acts on the output channels linked to it.

Indexed by trigger channel number, 1 or 2 (index 0 is unused). These are Pulse Pal's trigger modes:

  • "Normal": a rising edge starts the linked channels. Channels that are playing ignore it.
  • "Toggle": a rising edge starts the linked channels, or stops those that are playing.
  • "Gated": a rising edge starts the linked channels, and a falling edge stops them, unless the other trigger channel is also gated, linked to them, and still high. With a play duration of 0, a channel plays for exactly as long as the TTL is high.
  • "Param Sync": a rising edge starts and stops nothing (the channel's links are ignored). It loads the settings most recently sent by SynthPalDevice.sync_to_device (or at the end of a SynthPalDevice.batch block), if any. This is how the next trial's settings are sent during the current trial and applied the instant it starts.

At a param sync edge, the frequency and both trigger modes change at once: they are shared by all channels. An output channel at its resting voltage takes its new settings at once. One that is playing (off ramp included) finishes on the settings it started with, at the new frequency, and takes the new ones the moment it reaches its resting voltage, so the next trigger plays them. A trigger mode sent by SynthPalDevice.sync_to_device applies from the next edge.

Only SynthPalDevice.sync_to_device is held back for the edge: with SynthPalDevice.auto_sync on, every assignment programs the device at once, also in param sync mode. So leaving param sync mode means assigning trigger_mode with auto_sync on. When no trigger channel is left in the mode, the device discards a stored set.

To start channels on the same edge, wire the TTL to the other trigger channel too: the settings load first, so the channels it starts play the new settings. Connecting, and set_default_params, take both trigger channels out of param sync mode.

Names are not case sensitive.

def set_screen_saver(self, enabled, timeout=1800):
1071    def set_screen_saver(self, enabled, timeout=1800):
1072        """Switch the device's screen saver on or off, and set its timeout.
1073
1074        With the screen saver on, the device dims its screen once it has
1075        been left alone for `timeout` seconds: no command from a computer,
1076        no rising edge on a trigger channel, and no joystick click or
1077        push. The next of these brings the screen back. The device keeps
1078        both settings in its EEPROM, shared with Pulse Pal firmware, and
1079        saves them once no channel is playing. A new device starts with
1080        the screen saver on and 1800 s.
1081
1082        Args:
1083            enabled: `True` to switch the screen saver on, `False` to
1084                switch it off.
1085            timeout: Seconds without activity before the screen dims, 1 to
1086                65535.
1087
1088        Raises:
1089            PulsePalError: If a value is out of range.
1090        """
1091        state = to_bool(enabled, "enabled")
1092        if isinstance(timeout, (bool, np.bool_)) \
1093                or not isinstance(timeout, numbers.Integral) \
1094                or not 1 <= timeout <= 65535:
1095            raise PulsePalError(
1096                "timeout must be a whole number of seconds from 1 to 65535. "
1097                f"Received {timeout!r}."
1098            )
1099        self._write_command(self._OP_SET_SCREEN_SAVER,
1100                            struct.pack("<BH", int(state), int(timeout)))
1101        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 a computer, no rising edge on a trigger channel, and no joystick click or push. The next of these brings the screen back. The device keeps both settings in its EEPROM, shared with Pulse Pal firmware, and saves them once no channel is playing. A new device starts with the screen saver on and 1800 s.

Arguments:
  • enabled: True to switch the screen saver on, False to switch it off.
  • timeout: Seconds without activity before the screen dims, 1 to 65535.
Raises:
  • PulsePalError: If a value is out of range.
def trigger(self, channels):
1107    def trigger(self, channels):
1108        """Trigger output channels in software.
1109
1110        Channels that are idle start from the beginning of their waveform
1111        cycle, together. Channels that are playing ignore it, as they
1112        ignore a trigger channel in normal mode.
1113
1114        ```python
1115        S.trigger(1)
1116        S.trigger([2, 4])     # a list, tuple or NumPy array
1117        ```
1118
1119        Args:
1120            channels: Output channel number 1-4, or several as a list,
1121                tuple or NumPy array.
1122        """
1123        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channels)]))

Trigger output channels in software.

Channels that are idle start from the beginning of their waveform cycle, together. Channels that are playing ignore it, as they ignore a trigger channel in normal mode.

S.trigger(1)
S.trigger([2, 4])     # a list, tuple or NumPy array
Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array.
def stop(self, channels=None):
1125    def stop(self, channels=None):
1126        """Stop playback. The stopped channels return to their resting
1127        voltage, over their off ramps.
1128
1129        Args:
1130            channels: Output channel number 1-4, or several as a list,
1131                tuple or NumPy array. `None` stops all channels.
1132        """
1133        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
1134        self._write_command(self._OP_STOP, bytes([bits]))

Stop playback. The stopped channels return to their resting voltage, over their off ramps.

Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array. None stops all channels.
def status(self):
1136    def status(self):
1137        """Read the device's playback state.
1138
1139        Returns:
1140            A `DeviceStatus`, with the channels that are playing, the
1141            samples per cycle, each channel's output range, and timing
1142            figures.
1143        """
1144        self._write_command(self._OP_GET_STATUS)
1145        values = struct.unpack(
1146            self._STATUS_FORMAT,
1147            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
1148        )
1149        playing_bits = values[0]
1150        range_names = list(OUTPUT_RANGES)
1151        return DeviceStatus(
1152            playing=[ch for ch in range(1, 5)
1153                     if playing_bits & (1 << (ch - 1))],
1154            samples_per_cycle=values[1],
1155            output_ranges=[None, *(range_names[i] if i < len(range_names)
1156                                   else None for i in values[2:6])],
1157            longest_interrupt_us=values[6] / 1000,
1158            late_updates=values[7],
1159        )

Read the device's playback state.

Returns:

A DeviceStatus, with the channels that are playing, the samples per cycle, each channel's output range, and timing figures.

def close(self):
1177    def close(self):
1178        """Close the connection to the device.
1179
1180        The device stops playback on all channels, each over its off ramp,
1181        and shows its own name on its screen again, in place of "PYTHON
1182        Connected". It keeps its settings, so TTL triggers still play the
1183        channels. Safe to call more than once. Called automatically when
1184        leaving a `with` block and when the object is garbage collected,
1185        so playback also stops when the last reference to the object
1186        goes.
1187
1188        """
1189        self._close(send_disconnect=True)

Close the connection to the device.

The device stops playback on all channels, each over its off ramp, and shows its own name on its screen again, in place of "PYTHON Connected". It keeps its settings, so TTL triggers still play the channels. Safe to call more than once. Called automatically when leaving a with block and when the object is garbage collected, so playback also stops when the last reference to the object goes.

class PulsePalError(builtins.Exception):
52class PulsePalError(Exception):
53    """Raised when communication with the device, or a setting, fails.
54
55    All three device classes (`pulsepal.PulsePalDevice`,
56    `pulsepal.WavePalDevice` and `pulsepal.SynthPalDevice`) raise it, for
57    serial reads that time out, short serial writes, commands the device
58    rejects, and values it cannot play, which are refused before anything
59    is sent.
60    """

Raised when communication with the device, or a setting, fails.

All three device classes (pulsepal.PulsePalDevice, pulsepal.WavePalDevice and pulsepal.SynthPalDevice) raise it, for serial reads that time out, short serial writes, commands the device rejects, and values it cannot play, which are refused before anything is sent.

__version__ = '3.0.0'

Version of this package.