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