pyTriggerSync 0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ #If this package was installed only to use the low-level driver, the PyQt library is not necessarily installed. In this case, importing stuff from main.py would generate an error
2
+ import importlib.util
3
+ package_name = 'PyQt5'
4
+ spec = importlib.util.find_spec(package_name)
5
+ if spec:
6
+ from .main import interface, gui
@@ -0,0 +1,9 @@
1
+ {
2
+ "delay": 1200,
3
+ "divider": 3,
4
+ "external_trigger": true,
5
+ "mode": 1,
6
+ "number_of_triggers": 5,
7
+ "polarity": 1,
8
+ "triggerduration": 1000
9
+ }
@@ -0,0 +1,535 @@
1
+ ''' Note: most of docstrings in this file have been generated automatically by Claude. AI can make mistakes'''
2
+
3
+ import serial
4
+ import serial.tools.list_ports
5
+
6
+ class pyTriggerSync():
7
+ """
8
+ Low-level driver to communicate with a Teensy microcontroller running the
9
+ ``Send_Trigger_Synced_With_External_Trigger_OnlyRemoteControl`` firmware over a
10
+ serial (USB) connection.
11
+
12
+ The Teensy generates an output trigger pulse synchronized to an external trigger
13
+ signal received on its input pin, either continuously (one output pulse per input
14
+ edge, optionally sub-sampled via :attr:`divider`) or in user-controlled bursts of a
15
+ fixed number of pulses (via :meth:`sendtrigger`). See the project's
16
+ ``SERIAL_PROTOCOL.md`` for the full list of serial commands understood by the
17
+ firmware.
18
+
19
+ Attributes
20
+ ----------
21
+ connected : bool
22
+ ``True`` if a device is currently connected, ``False`` otherwise.
23
+ port : str
24
+ Serial port used for the connection (e.g. ``'COM4'``). Set at construction time
25
+ and updated by :meth:`connect_device` to reflect the port actually connected to.
26
+ baudrate : int
27
+ Baud rate used for the serial connection. Must match the firmware (115200).
28
+ timeout : float
29
+ Read timeout (in seconds) used for the serial connection. Note that this also
30
+ bounds how long :meth:`sendtrigger` will wait for the immediate acknowledgement
31
+ of a ``trg`` command - not for the burst to actually complete (see
32
+ :meth:`sendtrigger`).
33
+ identifier : str
34
+ Substring expected at the start of the device's identity string (the answer to
35
+ ``idn?``). Used by :meth:`list_devices` to recognize a compatible device.
36
+ device : serial.Serial
37
+ The underlying PySerial connection. Only valid while :attr:`connected` is
38
+ ``True``; created by :meth:`connect_device`.
39
+ """
40
+
41
+ def __init__(self,port='COM4', baudrate=115200, timeout=0.1):
42
+ """
43
+ Parameters
44
+ ----------
45
+ port : str, optional
46
+ Serial port to connect to by default (used by :meth:`connect_device` when
47
+ no port is explicitly specified). Default is ``'COM4'``.
48
+ baudrate : int, optional
49
+ Baud rate to use for the serial connection. Must match the firmware
50
+ (115200). Default is ``115200``.
51
+ timeout : float, optional
52
+ Read timeout (in seconds) for the serial connection. Default is ``0.1``.
53
+ """
54
+ self.connected = False
55
+ self.port = port
56
+ self.baudrate = baudrate
57
+ self.timeout = timeout
58
+ self.identifier = 'Send_Trigger_Synced_With_External_Trigger'
59
+
60
+ def list_devices(self):
61
+ '''
62
+ Scan all serial ports currently visible to the system and check whether any of
63
+ them is running compatible firmware, by briefly connecting to each one in turn
64
+ and comparing its identity string (the answer to ``idn?``) against
65
+ :attr:`identifier`.
66
+
67
+ Each candidate port is connected to and disconnected from in turn; only ports
68
+ that open successfully and respond with a matching identity are kept.
69
+
70
+ Returns
71
+ -------
72
+ list_valid_devices : list of str
73
+ A list of all found valid devices. Each element is a human-readable string
74
+ in the format ``"<port>: <description> [<hwid>]"``.
75
+
76
+ Raises
77
+ ------
78
+ RuntimeError
79
+ If a device is already connected (scanning would interfere with the active
80
+ connection). Disconnect first.
81
+ '''
82
+ if self.connected:
83
+ raise RuntimeError("Cannot scan for devices while a device is connected. Disconnect first.")
84
+ ports = serial.tools.list_ports.comports()
85
+ list_valid_devices =[]
86
+ for port, desc, hwid in sorted(ports):
87
+ (Msg,ID) = self.connect_device(port)
88
+ if ID == 1:
89
+ try:
90
+ device_identity = self.identity
91
+ if device_identity and device_identity.startswith(self.identifier):
92
+ list_valid_devices.append(f"{port}: {desc} [{hwid}]")
93
+ except Exception:
94
+ pass
95
+ finally:
96
+ #Always disconnect, even if reading the identity raised - otherwise the
97
+ #port is left open and the handle is discarded on the next loop iteration.
98
+ self.disconnect_device()
99
+ self.list_valid_devices = list_valid_devices
100
+ return self.list_valid_devices
101
+
102
+ def connect_device(self,port = None, baudrate = None, timeout = None):
103
+ '''
104
+ Attempt to connect to a device on the specified serial port.
105
+
106
+ Upon a successful connection, all relevant device parameters (mode, polarity,
107
+ divider, delay, trigger duration, number of triggers) are read once and cached
108
+ in the corresponding private attributes, so that code relying on the cached
109
+ values (e.g. :meth:`sendtrigger`, which reads the cached mode directly to avoid
110
+ an extra round-trip delay right before firing a trigger) is correct immediately
111
+ after connecting, without requiring an explicit read first.
112
+
113
+ Parameters
114
+ ----------
115
+ port : str, optional
116
+ Serial port to connect to (e.g. ``'COM4'``). If not specified, :attr:`port`
117
+ is used.
118
+ baudrate : int, optional
119
+ Baud rate for the connection. If not specified, :attr:`baudrate` is used.
120
+ timeout : float, optional
121
+ Read timeout (in seconds) for the connection. If not specified,
122
+ :attr:`timeout` is used.
123
+
124
+ Returns
125
+ -------
126
+ (Msg, ID) : (str, int)
127
+ ``Msg`` is a confirmation message, or the exception raised while
128
+ connecting. ``ID`` is 1 if the connection (including reading back all
129
+ device parameters) was successful, 0 otherwise.
130
+ '''
131
+ if port == None:
132
+ port = self.port
133
+ if baudrate == None:
134
+ baudrate = self.baudrate
135
+ if timeout == None:
136
+ timeout = self.timeout
137
+ try:
138
+ self.device = serial.Serial(port = port, baudrate = baudrate, timeout = timeout)
139
+ self.connected = True
140
+ self.port = port #remember the port actually used, not just the configured default
141
+ #Populate all cached attributes by querying the device now, so that code relying
142
+ #on the cached values (e.g. sendtrigger()'s use of self._mode, kept deliberately
143
+ #cached to avoid an extra round-trip delay right before firing a trigger) is
144
+ #correct immediately after connecting, without requiring an explicit read first.
145
+ _ = self.mode
146
+ _ = self.polarity
147
+ _ = self.divider
148
+ _ = self.delay
149
+ _ = self.triggerduration
150
+ _ = self.number_of_triggers
151
+ ID = 1
152
+ Msg = 'Device connected on ' + self.port
153
+ except Exception as e:
154
+ ID = 0
155
+ Msg = e
156
+ #If the port opened but querying the device failed (e.g. it didn't respond as
157
+ #expected), don't leave a half-open connection behind.
158
+ try:
159
+ self.device.close()
160
+ except Exception:
161
+ pass
162
+ self.connected = False
163
+ return (Msg,ID)
164
+
165
+ def disconnect_device(self):
166
+ '''
167
+ Disconnect the currently connected device, closing the underlying serial
168
+ connection.
169
+
170
+ Returns
171
+ -------
172
+ (Msg, ID) : (str, int)
173
+ ``Msg`` is a confirmation message, or the exception raised while
174
+ disconnecting. ``ID`` is 1 if disconnection was successful, 0 otherwise.
175
+ '''
176
+ try:
177
+ self.device.close()
178
+ ID = 1
179
+ Msg = 'Device ' + self.port + ' succesfully disconnected.'
180
+ except Exception as e:
181
+ ID = 0
182
+ Msg = e
183
+ if(ID==1):
184
+ self.connected = False
185
+ return (Msg,ID)
186
+
187
+ def check_valid_connection(self):
188
+ '''
189
+ Verify that a device is currently connected.
190
+
191
+ Raises
192
+ ------
193
+ RuntimeError
194
+ If no device is currently connected.
195
+ '''
196
+ if not(self.connected):
197
+ raise RuntimeError("No device is currently connected.")
198
+
199
+ def query(self, q):
200
+ '''
201
+ Send a command to the device and return its reply.
202
+
203
+ Clears any stale unread data from the input buffer first, then writes ``q``
204
+ (terminated with a newline) and reads back exactly one reply line.
205
+
206
+ Parameters
207
+ ----------
208
+ q : str
209
+ Command to send (e.g. ``"mode?"``). A trailing newline is added
210
+ automatically if not already present.
211
+
212
+ Returns
213
+ -------
214
+ str
215
+ The device's reply, decoded and stripped of leading/trailing whitespace.
216
+ Empty if no reply was received within the configured :attr:`timeout`.
217
+ '''
218
+ self.device.reset_input_buffer() #This makes sure that there isn't any previous unread data in the serial buffer, which would spoil the data read with the readline() call.
219
+ self.device.write(bytes(q.strip() + '\n', 'utf-8'))
220
+ data = self.device.readline()
221
+ return data.decode().strip()
222
+
223
+ @property
224
+ def identity(self):
225
+ '''
226
+ str: The device's identity string (the answer to ``idn?``).
227
+
228
+ Raises
229
+ ------
230
+ RuntimeError
231
+ If no device is currently connected.
232
+ '''
233
+ self.check_valid_connection()
234
+ identity_string = self.query("IDN?\n")
235
+ return identity_string
236
+
237
+ @property
238
+ def mode(self):
239
+ '''
240
+ int: The device's current operating mode: ``0`` for "Continuous
241
+ Trigger" (every qualifying input edge automatically fires an output pulse) or
242
+ ``1`` for "Trigger Controlled by User" (output pulses only fire in response to
243
+ :meth:`sendtrigger`).
244
+
245
+ Reading this property queries the device (``mode?``) and caches the result.
246
+
247
+ Setting this property writes the new mode to the device (``mode <value>``)
248
+ and reads it back to confirm the change took effect.
249
+
250
+ Raises
251
+ ------
252
+ RuntimeError
253
+ If no device is currently connected; or (setter only) if the device did
254
+ not acknowledge the command, or if the mode read back does not match the
255
+ requested value.
256
+ ValueError
257
+ (setter only) If the assigned value is not ``0`` or ``1``.
258
+ '''
259
+ self.check_valid_connection()
260
+ self._mode = int(self.query("mode?\n"))
261
+ return self._mode
262
+
263
+ @mode.setter
264
+ def mode(self, new_mode):
265
+ self.check_valid_connection()
266
+ if new_mode in [0,1]:
267
+ answer = self.query("mode " + str(new_mode) + "\n")
268
+ if answer != '0':
269
+ raise RuntimeError(f"Device was not able to set the mode correctly. Code returned: " + answer)
270
+ self._mode = int(self.query("mode?\n"))
271
+ if self._mode == new_mode:
272
+ return self._mode
273
+ else:
274
+ raise RuntimeError(f"Device acknowledged the command, but the mode read back ({self._mode}) does not match the requested value ({new_mode}).")
275
+ else:
276
+ raise ValueError(f"Input parameter must be equal to either 0 or 1")
277
+ return None
278
+
279
+ @property
280
+ def polarity(self):
281
+ '''
282
+ int: The device's current output trigger polarity: ``0`` for
283
+ "Negative" (output idles high, pulses low) or ``1`` for "Positive" (output
284
+ idles low, pulses high).
285
+
286
+ Reading this property queries the device (``polarity?``) and caches the
287
+ result.
288
+
289
+ Setting this property writes the new polarity to the device
290
+ (``polarity <value>``) and reads it back to confirm the change took effect.
291
+
292
+ Raises
293
+ ------
294
+ RuntimeError
295
+ If no device is currently connected; or (setter only) if the device did
296
+ not acknowledge the command, or if the polarity read back does not match
297
+ the requested value.
298
+ ValueError
299
+ (setter only) If the assigned value is not ``0`` or ``1``.
300
+ '''
301
+ self.check_valid_connection()
302
+ self._polarity = int(self.query("polarity?\n"))
303
+ return self._polarity
304
+
305
+ @polarity.setter
306
+ def polarity(self, new_polarity):
307
+ self.check_valid_connection()
308
+ if new_polarity in [0,1]:
309
+ answer = self.query("polarity " + str(new_polarity) + "\n")
310
+ if answer != '0':
311
+ raise RuntimeError(f"Device was not able to set the polarity correctly. Code returned: " + answer)
312
+ self._polarity = int(self.query("polarity?\n"))
313
+ if self._polarity == new_polarity:
314
+ return self._polarity
315
+ else:
316
+ raise RuntimeError(f"Device acknowledged the command, but the polarity read back ({self._polarity}) does not match the requested value ({new_polarity}).")
317
+ else:
318
+ raise ValueError(f"Input parameter must be equal to either 0 or 1")
319
+ return None
320
+
321
+ @property
322
+ def divider(self):
323
+ '''
324
+ int: The device's current sub-sampling divider. In Continuous Trigger
325
+ mode, one output pulse is emitted for every :attr:`divider` input edges (has
326
+ no effect during a :meth:`sendtrigger`-initiated burst).
327
+
328
+ Reading this property queries the device (``divider?``) and caches the
329
+ result.
330
+
331
+ Setting this property writes the new divider to the device
332
+ (``divider <value>``) and reads it back to confirm the change took effect.
333
+
334
+ Raises
335
+ ------
336
+ RuntimeError
337
+ If no device is currently connected; or (setter only) if the device did
338
+ not acknowledge the command, or if the divider read back does not match
339
+ the requested value.
340
+ ValueError
341
+ (setter only) If the assigned value cannot be converted to a positive
342
+ ``int``.
343
+ '''
344
+ self.check_valid_connection()
345
+ self._divider = int(self.query("divider?\n"))
346
+ return self._divider
347
+
348
+ @divider.setter
349
+ def divider(self, new_divider):
350
+ self.check_valid_connection()
351
+ try:
352
+ new_divider = int(new_divider)
353
+ except:
354
+ raise ValueError(f"Input parameter must be a valid integer")
355
+ if new_divider <= 0 :
356
+ raise ValueError(f"Input parameter must be positive")
357
+ answer = self.query("divider " + str(new_divider) + "\n")
358
+ if answer != '0':
359
+ raise RuntimeError(f"Device was not able to set the divider correctly. Code returned: " + answer)
360
+ self._divider = int(self.query("divider?\n"))
361
+ if self._divider == new_divider:
362
+ return self._divider
363
+ else:
364
+ raise RuntimeError(f"Device was not able to set the divider correctly.")
365
+ return None
366
+
367
+ @property
368
+ def delay(self):
369
+ '''
370
+ int: The device's current delay (in nanoseconds) between an input
371
+ trigger edge and the generated output pulse.
372
+
373
+ Reading this property queries the device (``delay?``) and caches the result.
374
+
375
+ Setting this property writes the new delay to the device (``delay <value>``)
376
+ and reads it back to confirm the change took effect.
377
+
378
+ Raises
379
+ ------
380
+ RuntimeError
381
+ If no device is currently connected; or (setter only) if the device did
382
+ not acknowledge the command, or if the delay read back does not match the
383
+ requested value.
384
+ ValueError
385
+ (setter only) If the assigned value cannot be converted to a non-negative
386
+ ``int``.
387
+ '''
388
+ self.check_valid_connection()
389
+ self._delay = int(self.query("delay?\n"))
390
+ return self._delay
391
+
392
+ @delay.setter
393
+ def delay(self, new_delay):
394
+ self.check_valid_connection()
395
+ try:
396
+ new_delay = int(new_delay)
397
+ except:
398
+ raise ValueError(f"Input parameter must be a valid integer")
399
+ if new_delay < 0 :
400
+ raise ValueError(f"Input parameter must be positive or zero")
401
+ answer = self.query("delay " + str(new_delay) + "\n")
402
+ if answer != '0':
403
+ raise RuntimeError(f"Device was not able to set the delay correctly. Code returned: " + answer)
404
+ self._delay = int(self.query("delay?\n"))
405
+ if self._delay == new_delay:
406
+ return self._delay
407
+ else:
408
+ raise RuntimeError(f"Device was not able to set the delay correctly.")
409
+ return None
410
+
411
+ @property
412
+ def triggerduration(self):
413
+ '''
414
+ int: The device's current output pulse duration, in nanoseconds.
415
+
416
+ Reading this property queries the device (``triggerduration?``) and caches
417
+ the result.
418
+
419
+ Setting this property writes the new duration to the device
420
+ (``triggerduration <value>``) and reads it back to confirm the change took
421
+ effect.
422
+
423
+ Raises
424
+ ------
425
+ RuntimeError
426
+ If no device is currently connected; or (setter only) if the device did
427
+ not acknowledge the command, or if the duration read back does not match
428
+ the requested value.
429
+ ValueError
430
+ (setter only) If the assigned value cannot be converted to a positive
431
+ ``int``.
432
+ '''
433
+ self.check_valid_connection()
434
+ self._triggerduration = int(self.query("triggerduration?\n"))
435
+ return self._triggerduration
436
+
437
+ @triggerduration.setter
438
+ def triggerduration(self, new_triggerduration):
439
+ self.check_valid_connection()
440
+ try:
441
+ new_triggerduration = int(new_triggerduration)
442
+ except:
443
+ raise ValueError(f"Input parameter must be a valid integer")
444
+ if new_triggerduration <= 0 :
445
+ raise ValueError(f"Input parameter must be positive")
446
+ answer = self.query("triggerduration " + str(new_triggerduration) + "\n")
447
+ if answer != '0':
448
+ raise RuntimeError(f"Device was not able to set the trigger duration correctly. Code returned: " + answer)
449
+ self._triggerduration = int(self.query("triggerduration?\n"))
450
+ if self._triggerduration == new_triggerduration:
451
+ return self._triggerduration
452
+ else:
453
+ raise RuntimeError(f"Device was not able to set the trigger duration correctly.")
454
+ return None
455
+
456
+ @property
457
+ def number_of_triggers(self):
458
+ '''
459
+ int: The number of output pulses a single :meth:`sendtrigger` call
460
+ will produce (only meaningful in "Trigger Controlled by User" mode).
461
+
462
+ Reading this property queries the device (``numpulses?``) and caches the
463
+ result.
464
+
465
+ Setting this property writes the new pulse count to the device
466
+ (``numpulses <value>``) and reads it back to confirm the change took effect.
467
+
468
+ Raises
469
+ ------
470
+ RuntimeError
471
+ If no device is currently connected; or (setter only) if the device did
472
+ not acknowledge the command, or if the value read back does not match the
473
+ requested value.
474
+ ValueError
475
+ (setter only) If the assigned value cannot be converted to a positive
476
+ ``int``.
477
+ '''
478
+ self.check_valid_connection()
479
+ self._number_of_triggers = int(self.query("numpulses?\n"))
480
+ return self._number_of_triggers
481
+
482
+ @number_of_triggers.setter
483
+ def number_of_triggers(self, new_number_of_triggers):
484
+ self.check_valid_connection()
485
+ try:
486
+ new_number_of_triggers = int(new_number_of_triggers)
487
+ except:
488
+ raise ValueError(f"Input parameter must be a valid integer")
489
+ if new_number_of_triggers <= 0:
490
+ raise ValueError(f"Input parameter must be positive")
491
+ answer = self.query("numpulses " + str(new_number_of_triggers) + "\n")
492
+ if answer != '0':
493
+ raise RuntimeError(f"Device was not able to set the number of triggers correctly. Code returned: " + answer)
494
+ self._number_of_triggers = int(self.query("numpulses?\n"))
495
+ if self._number_of_triggers == new_number_of_triggers:
496
+ return self._number_of_triggers
497
+ else:
498
+ raise RuntimeError(f"Device was not able to set the number of triggers correctly.")
499
+ return None
500
+
501
+ def sendtrigger(self):
502
+ '''
503
+ Start a burst of output trigger pulses. Only valid in "Trigger Controlled by
504
+ User" mode (:attr:`mode` == 1).
505
+
506
+ Sends the ``trg`` command and waits for the device's immediate
507
+ acknowledgement. The acknowledgement only confirms that the device accepted
508
+ the command and armed the burst - it does NOT mean the burst has completed.
509
+ Completion depends on real trigger edges arriving on the device's input pin
510
+ (one edge per pulse of :attr:`number_of_triggers`) and is reported later,
511
+ asynchronously, directly by the device - it is not read by this method.
512
+
513
+ Returns
514
+ -------
515
+ bool
516
+ ``True`` if the device acknowledged the command, ``False`` otherwise.
517
+
518
+ Raises
519
+ ------
520
+ RuntimeError
521
+ If the device is currently in "Continuous Trigger" mode (:attr:`mode` ==
522
+ 0), in which case sending a single trigger is not applicable.
523
+ '''
524
+ #NOTE: uses the cached self._mode (not the 'mode' property) deliberately, to avoid
525
+ #the extra round-trip delay of querying the device right before firing a trigger.
526
+ #self._mode is populated as soon as the device connects (see connect_device()),
527
+ #so it is guaranteed to exist and to be correct as long as nothing outside this
528
+ #driver changes the device's mode without going through this driver's mode setter.
529
+ if self._mode == 0:
530
+ raise RuntimeError(f"Device is in modality 'Continuous Trigger', it is not possible to send a single trigger. Change the mode to 'Trigger Controlled by User' first.")
531
+ data = self.query("trg 1\n")
532
+ if data == '0':
533
+ return True
534
+ else:
535
+ return False
Binary file
Binary file
Binary file
Binary file