sqidevice 2.0.0__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.
sqidevice/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # Public device class
2
+ from .sqidevice import SQIDevice, CRLF
3
+
4
+ # Error handling classes
5
+ from .sqidevice import DeviceError, USBError
6
+
7
+ # Helper functions
8
+ from .sqidevice import convert_measurement, load_script
9
+
10
+ # Define * imports
11
+ __all__ = [
12
+ "SQIDevice",
13
+ "CRLF",
14
+ "DeviceError",
15
+ "USBError",
16
+ "convert_measurement",
17
+ "load_script",
18
+ ]
sqidevice/sqidevice.py ADDED
@@ -0,0 +1,801 @@
1
+ """
2
+ Santec Quantum Instrument device class
3
+ Simplifies communication and control of compatible devices
4
+
5
+ (c) Santec Australia, 2016--2025
6
+ http://www.santec.com/
7
+ """
8
+
9
+ import logging
10
+ import select
11
+ import socket
12
+ import threading
13
+ import contextlib
14
+ import string
15
+ import time
16
+ import re
17
+
18
+ from typing import Union, Optional, Dict, Tuple, Generator, List
19
+ from struct import unpack
20
+ import functools
21
+ import serial
22
+
23
+ # Delimiter for "End of Message" (EOM)
24
+ CRLF = b"\r\n"
25
+ # Define convenience type that represents either a unicode string, or bytes array
26
+ DATA = Union[str, bytes]
27
+
28
+ # Specify the default level for new connections
29
+ # This reduces verbosity when running the discovery process
30
+ DEFAULT_LOG_LEVEL = logging.INFO
31
+
32
+ UNIT_SYNONYMS = {
33
+ "degC": "°C",
34
+ }
35
+
36
+
37
+ ###
38
+ ### EXCEPTION CLASSES
39
+ ###
40
+ class DeviceError(RuntimeError):
41
+ """
42
+ Helper class representing an error response raised by the device, as distinct from a comms error during transport.
43
+ Retains the query string that was sent to the device, the error message response, and the device instance.
44
+ """
45
+
46
+ def __init__(self, resp: DATA, query: Optional[str] = None, dev: Optional["SQIDevice"] = None):
47
+ if isinstance(resp, bytes):
48
+ resp = resp.decode(errors="replace")
49
+ if resp.startswith("ERR:"):
50
+ resp = resp[4:]
51
+ self.resp = resp.strip()
52
+ self.query = None if query is None else str(query).strip()
53
+ self.dev = dev
54
+
55
+ def __str__(self) -> str:
56
+ """Returns the error message response from the device, with any "ERR" prefix removed."""
57
+ return self.resp
58
+
59
+ def __repr__(self) -> str:
60
+ """Provide a string summary of the interaction for diagnostic purposes"""
61
+ s = 'DeviceError("%s"' % self.resp.strip()
62
+ if self.query is not None:
63
+ s += ', tried "%s"' % self.query
64
+ if self.dev is not None:
65
+ s += ", <%s>" % self.dev.connection
66
+ return s + ")"
67
+
68
+
69
+ class USBError(OSError):
70
+ """
71
+ Helper class to wrap and translate errors related to the USB interface.
72
+ """
73
+
74
+ def __init__(self, serialException: Union[BaseException, str]):
75
+ # pyserial has a very unfortunate way of expressing configuration errors as "something went wrong"
76
+ # this arises if there's an issue with the CDC driver (e.g. did not respond to IRQ fast enough)
77
+ # so replace that with a clearer error message
78
+ s = serialException.args[0] if isinstance(serialException, Exception) else str(serialException)
79
+ if "went wrong" in repr(serialException):
80
+ s = "Error configuring USB port; unplug USB then try again"
81
+ elif "ClearCommError" in s:
82
+ # typically if the device is unplugged/turned off, this is the error that gets raised
83
+ s = "Device disconnected"
84
+ elif s.startswith("["):
85
+ s = s.split("]", 1)[1]
86
+ else:
87
+ # otherwise, remove the (useless) additional information about the Windows error
88
+ s = s.split(":", 1)[0]
89
+ super().__init__(s)
90
+
91
+ @staticmethod
92
+ def translate(func):
93
+ """Decorator to transform unhandled SerialException errors into USBError instances."""
94
+
95
+ def wrapped(*args, **kwargs):
96
+ try:
97
+ return func(*args, **kwargs)
98
+ except serial.SerialException as E:
99
+ raise USBError(E) from E
100
+
101
+ return functools.update_wrapper(wrapped, func)
102
+
103
+
104
+ ###
105
+ ### HELPER FUNCTIONS
106
+ ###
107
+ def check_version(required_ver: str, test_ver: str):
108
+ """
109
+ Returns True if the decimal-delimited string "test_ver" is at least "required_ver".
110
+ Note: Deprecated by packaging.version
111
+ """
112
+ for astr, bstr in zip(required_ver.split("."), test_ver.split(".")):
113
+ a, b = int(astr, 10), int(bstr, 10)
114
+ if b > a:
115
+ return True
116
+ if b < a:
117
+ return False
118
+ return True
119
+
120
+
121
+ def convert_measurement(
122
+ val: Union[str, int, float], units_out: Optional[str], default_units: str = "", type: callable = float
123
+ ):
124
+ """
125
+ Simple function for processing strings containing SI units and converting to expected units.
126
+ @param val: Value to be converted. Should be one of
127
+ - String with units (e.g. "20 kHz").
128
+ Requires a space between the numeric part and the units part.
129
+ - String without units (e.g. "20")
130
+ - Numerical value (int or float)
131
+ @param units_out: String containing the target SI units, or None to indicate no processing
132
+ should be performed.
133
+ @param default_units: Units to associate with `val` if it doesn't already specify units.
134
+ Expected to be specified if `val` is not a string.
135
+ @param type: Callable to convert `val` to the target type, typically `float` or `int`
136
+
137
+ @raises ValueError: `val` is interpreted as the empty string, or the mapping between the input and output units
138
+ is not implemented.
139
+ """
140
+ # split the string into usable parts
141
+ units_in = default_units
142
+ if isinstance(val, str):
143
+ # explicitly handle the scenario of comma-separated values
144
+ # e.g. "1, 2, 3" should not be interpreted as having units
145
+ parts = val.split(",", 1)[0].split()
146
+ if not parts:
147
+ raise ValueError("Empty string")
148
+ val = type(parts[0])
149
+ if len(parts) > 1:
150
+ units_in = parts[1]
151
+ # maybe we are ignoring units altogether
152
+ if units_out is None:
153
+ return val
154
+ # maybe there is no conversion to perform
155
+ if units_in == units_out:
156
+ return val
157
+ # we just consider differences in SI scalings
158
+ pows = {
159
+ "T": 12,
160
+ "G": 9,
161
+ "M": 6,
162
+ "k": 3,
163
+ "m": -3,
164
+ "u": -6,
165
+ "n": -9,
166
+ "p": -12,
167
+ "f": -15,
168
+ }
169
+ pow_in = pows.get(units_in[0], 0) if len(units_in) > 1 else 0
170
+ if pow_in:
171
+ units_in = units_in[1:]
172
+ pow_out = pows.get(units_out[0], 0) if len(units_out) > 1 else 0
173
+ if pow_out:
174
+ units_out = units_out[1:]
175
+ # raise an exception if the units still don't match
176
+ # allow synonyms to be considered equivalent
177
+ if UNIT_SYNONYMS.get(units_in, units_in) != UNIT_SYNONYMS.get(units_out, units_out):
178
+ raise ValueError("Units mismatch")
179
+ return val * 10 ** (pow_in - pow_out)
180
+
181
+
182
+ ###
183
+ ### USB-SERIAL INTERFACE
184
+ ###
185
+ class SQIDeviceUSB:
186
+ """
187
+ Low-level communications wrapper for USB device communication via virtual serial port (CDC)
188
+ """
189
+
190
+ def __init__(self, port: str, timeout: float):
191
+ try:
192
+ self.dev = serial.Serial(
193
+ port,
194
+ baudrate=115200,
195
+ bytesize=8,
196
+ parity="N",
197
+ stopbits=1,
198
+ timeout=timeout,
199
+ writeTimeout=None, # blocking on write
200
+ )
201
+ except serial.SerialException as E:
202
+ raise USBError(E) from E
203
+
204
+ def close(self):
205
+ """Close the underlying device handle"""
206
+ self.dev.close()
207
+
208
+ @USBError.translate
209
+ def pending(self) -> bool:
210
+ """Check whether any data is waiting to be received"""
211
+ return self.dev.in_waiting > 0
212
+
213
+ @USBError.translate
214
+ def recv(self, size: int) -> bytes:
215
+ """Receive a chunk of data from device"""
216
+ if size < 0:
217
+ size = max(1, self.dev.in_waiting)
218
+ return self.dev.read(size)
219
+
220
+ @USBError.translate
221
+ def send(self, data: bytes) -> None:
222
+ """Send bytes to device"""
223
+ self.dev.write(data)
224
+
225
+ @property
226
+ @USBError.translate
227
+ def timeout(self) -> float:
228
+ """Return the communications timeout (in seconds)"""
229
+ return self.dev.timeout
230
+
231
+ @timeout.setter
232
+ @USBError.translate
233
+ def timeout(self, timeout: float) -> None:
234
+ """Set the communications timeout (in seconds)"""
235
+ self.dev.timeout = timeout
236
+
237
+
238
+ ###
239
+ ### ETH-TCP INTERFACE
240
+ ###
241
+ class SQIDeviceETH:
242
+ """
243
+ Low-level communications wrapper for ETH device communication via TCP port
244
+ """
245
+
246
+ def __init__(self, address: str, port: int, timeout: float):
247
+ dev = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
248
+ dev.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
249
+ dev.settimeout(timeout)
250
+ dev.connect((address, port))
251
+ self.dev = dev
252
+
253
+ def close(self):
254
+ """Close the underlying socket"""
255
+ self.dev.close()
256
+
257
+ def pending(self) -> bool:
258
+ """Check whether any data is waiting to be received"""
259
+ try:
260
+ sel = select.select([self.dev], [], [], 0)
261
+ return len(sel[0]) > 0
262
+ except (OSError, ValueError):
263
+ # socket is invalid or was invalidated DURING the timeout period
264
+ # so return False since there is no data pending
265
+ return False
266
+
267
+ def recv(self, size: int) -> bytes:
268
+ """Receive a chunk of data from device"""
269
+ return self.dev.recv(256 if size < 0 else size)
270
+
271
+ def send(self, data: bytes) -> None:
272
+ """Send bytes to device"""
273
+ self.dev.sendall(data)
274
+
275
+ @property
276
+ def timeout(self) -> float:
277
+ """Return the communications timeout (in seconds)"""
278
+ return self.dev.gettimeout()
279
+
280
+ @timeout.setter
281
+ def timeout(self, timeout: float) -> None:
282
+ """Set the communications timeout (in seconds)"""
283
+ self.dev.settimeout(timeout)
284
+
285
+
286
+ ###
287
+ ### DEVICE CLASS
288
+ ###
289
+ class SQIDevice:
290
+ """
291
+ The high-level class for interacting with Santec Quantum Instruments.
292
+
293
+ Key features include
294
+ - Support for either socket (ETH) or COM (USB) underlying interface
295
+ - Handles end-of-message (EOM) termination
296
+ - Handles byte-encoding to/from str
297
+ - Caches information about the device
298
+ - Provides high-level queries such as ask(), ask_dict() and ask_val()
299
+ - Enforces mutex locking on queries to avoid multithreading issues
300
+ - Provides simple reconnection mechanism
301
+
302
+ @param addr: Address string, either starting with "COM" for USB COM port,
303
+ else interpreted as an IP address with optional port number
304
+ separated by ":".
305
+ @param port: Port number to use for socket connection, ignored on USB.
306
+ Defaults to MOG standard port 7802, ignored if specified as part
307
+ of the "addr" string.
308
+ @param timeout: Timeout in seconds to apply to the connection. Negative value
309
+ implies "no timeout" which should not be used in GUI applications.
310
+ @param minver: Optional minimum version string to check for at connection.
311
+ Must be of the form "{MAJ}.{MIN}.{REL}", and raises AssertionError
312
+ if the version does not match.
313
+ @param check: Bool to indicate whether the connection should be sanity-checked,
314
+ which automatically queries info_dict()
315
+ """
316
+
317
+ def __init__(
318
+ self, addr: str, port: Optional[int] = None, timeout: float = 1, minver: str = None, check: bool = True
319
+ ):
320
+ assert len(addr), "No address specified"
321
+ self.dev = None # The underlying connection object
322
+ self._dev_info = {} # A dict of values parsed from the INFO query
323
+ self._attempts = 0 # Number of connection attempts
324
+ self._retry_delay = 0.01 # Seconds to wait before reattempting on a RETRY error
325
+
326
+ # Regularise the device connection address
327
+ if addr.startswith("COM") or addr == "USB":
328
+ if port is not None:
329
+ addr = "COM%d" % port
330
+ addr = addr.split(" ", 1)[0]
331
+ elif ":" not in addr:
332
+ if port is None:
333
+ port = 7802 # Default port
334
+ addr = "%s:%d" % (addr, port)
335
+ self.connection = addr
336
+
337
+ # Create a device-specific logging instance
338
+ self.logger = logging.getLogger(f"[{self.connection}]")
339
+ self.logger.setLevel(DEFAULT_LOG_LEVEL)
340
+
341
+ # Initiate the connection to the device
342
+ self.lock = threading.RLock()
343
+ self.reconnect(timeout, check)
344
+ if minver is not None:
345
+ ver = self.versions()["UC"]
346
+ assert check_version(minver, ver), "Unsupported firmware version; Please update to v%s or newer" % minver
347
+
348
+ def __repr__(self) -> str:
349
+ """Return a string representation containing the device connection address"""
350
+ return 'SQIDevice("%s")' % self.connection
351
+
352
+ def close(self):
353
+ """Close the underlying device connection"""
354
+ if self.connected():
355
+ # NB: Deliberately don't acquire the lock here
356
+ # This ensures that _any_ thread calling "close" will abort any ongoing (blocking) comms operations
357
+ self.dev.close()
358
+ self.dev = None
359
+
360
+ def reconnect(self, timeout: Optional[float] = None, check: bool = True):
361
+ """
362
+ Attempt to reestablish connection with the device using the known address.
363
+ @param timeout: Optional timeout (in seconds) to specify for the connection.
364
+ @param check: If True, sanity-checks the device details after establishing the connection.
365
+ """
366
+ # close the handle if open - this is _required_ on USB
367
+ # ensures that self.dev is None, so connected() is False
368
+ with self.lock:
369
+ self.close()
370
+ # keep track of the number of unsuccessful reconnect attempts
371
+ self._attempts = max(self._attempts, 0) + 1
372
+ if timeout is not None:
373
+ self._timeout = timeout
374
+ if check:
375
+ self.logger.info("Connecting to %s, attempt %d", self.connection, self._attempts)
376
+ # perform the reconnection
377
+ if self.connection.startswith("COM"):
378
+ dev = SQIDeviceUSB(self.connection, self._timeout)
379
+ else:
380
+ addr, port = self.connection.split(":")
381
+ dev = SQIDeviceETH(addr, int(port), self._timeout)
382
+ # connection was successful, so reacquire the lock before changing state [#126]
383
+ # holding the lock while attempting the connection can hang other threads that would rather just fail
384
+ # immediately [#96], therefore only reacquire the lock upon connection complete
385
+ with self.lock:
386
+ self.dev = dev
387
+ # check the connection?
388
+ if check:
389
+ self._check_reconnect()
390
+ self.logger.name = f"[{self['serial']}-{self['type']}]"
391
+ else:
392
+ # reset the stored information
393
+ self._dev_info = {}
394
+ # connection successful, but make negative to track how many failed attempts were required
395
+ self._attempts = -self._attempts
396
+ self.logger.debug("Connected")
397
+
398
+ def _check_reconnect(self):
399
+ """
400
+ Helper function that verifies that "reconnecting" connects to the same device.
401
+ In particular, this handles the case where a USB port is disconnected and connected to a different product,
402
+ or if the DHCP lease expires and a different product is allocated the previously-connected IP address.
403
+
404
+ @raises RuntimeError: Failed to query information about the device, or does not match expected details.
405
+ """
406
+ old_dict = self._dev_info
407
+ try:
408
+ info = self.query_info()
409
+ except Exception as exc:
410
+ raise RuntimeError("Device did not respond to query") from exc
411
+ # sanity-check there is something to compare against
412
+ if not old_dict:
413
+ return
414
+ # verify that this is the same device that was connected previously [#119]
415
+ # note that some details may have changed because of reboot or firmware update, so don't compare
416
+ # the entire dict
417
+ if old_dict["type"] != info["type"] or old_dict["serial"] != info["serial"]:
418
+ self.logger.error(
419
+ "Device at %s is %s-%s instead of %s-%s",
420
+ self.connection,
421
+ info["type"],
422
+ info["serial"],
423
+ old_dict["type"],
424
+ old_dict["serial"],
425
+ )
426
+ # prevent the "intended" info from being replaced
427
+ self._dev_info = old_dict
428
+ raise RuntimeError("Reconnected to different device")
429
+
430
+ def connected(self) -> bool:
431
+ """
432
+ Returns True if a valid device connection exists, or False if the connection was closed.
433
+ Does not actively verify the connection, since that requires waiting for a response.
434
+ """
435
+ return self.dev is not None
436
+
437
+ def __bool__(self):
438
+ """Convenience wrapper for connected()"""
439
+ return self.connected()
440
+
441
+ def _check(self):
442
+ """Internal assertion check to confirm the connection is valid"""
443
+ assert self.connected(), "Not connected"
444
+
445
+ @contextlib.contextmanager
446
+ def _locked(self):
447
+ """Internal helper function for checking the connection around acquiring the mutex lock"""
448
+ # Check the connection is valid BEFORE attempting to acquire the lock
449
+ # Enables the caller to fail fast if a reconnect attempt is being made in another thread
450
+ self._check()
451
+ with self.lock:
452
+ # Check the connection is valid AFTER acquiring the lock, which might be a long time later
453
+ # if another thread times out on the connection.
454
+ # e.g. another thread holds the lock but times out
455
+ self._check()
456
+ yield
457
+
458
+ def is_iap(self) -> bool:
459
+ """Returns True if the device is in IAP (firmware update) mode"""
460
+ # Our "standard" IAP reports "IAP" in the version part of the INFO string
461
+ return self["is_iap"] if self._dev_info else False
462
+
463
+ def keys(self) -> List[str]:
464
+ """Return the keys available as part of the info query"""
465
+ return self._dev_info.keys()
466
+
467
+ def __getitem__(self, name: str) -> str:
468
+ """Convenience access to the elements of the cached info_dict() query"""
469
+ if not self._dev_info:
470
+ self.query_info()
471
+ return self._dev_info[name]
472
+
473
+ def query_info(self) -> Dict[str, str]:
474
+ """
475
+ Interpret the INFO statement into a dict of items to simplify processing.
476
+ Key strings are all lower-case.
477
+
478
+ @param query: Clear cached information and explicitly query, @see info()
479
+ """
480
+ # remove the cached info dict
481
+ self._dev_info = {}
482
+ # query the INFO statement from the device, allowing for bad EEPROM
483
+ info = self.ask("info", allow_bytes=True)
484
+ # handle the scenario where we receive unexpected garbage
485
+ if isinstance(info, bytes):
486
+ # \xFF represents an unterminated EEPROM string
487
+ info = info.split(b"\xff", 1)[0].decode(errors="replace")
488
+ # split into components, keeping serial and name together
489
+ parts = info.split(maxsplit=3)
490
+ # pad out to 4 components
491
+ parts += [""] * (4 - len(parts))
492
+
493
+ # the correct form is "[TYPE][-DEV] [CODE]-[MODEL]-[REV] [VER] [NAME] [SERIAL]"
494
+ # where TYPE is the product name (e.g. ARF, QRF, LDD)
495
+ # DEV identifies an internal (DEBUG) build, not for release
496
+ # CODE is the board type running this UC (e.g. B3010, B8110)
497
+ # MODEL is the (optical) model-specific information (e.g. 421 for ARFs)
498
+ # REV is the string containing the HARDWARE revision
499
+ # VER is the version string of the RUNNING firmware (e.g. UC+FPGA or IAP as relevant)
500
+ # NAME is the (optical) user-specified name of the unit
501
+ # SERIAL is the serial number of the unit, containing the ASSEMBLY revision
502
+ # if NAME is not specified, SERIAL is used as the NAME
503
+
504
+ match = re.match(r"(\w+)\-?(\w+)?-R(\d+)", parts[1])
505
+ if match:
506
+ code = match[1]
507
+ model = match[2]
508
+ rev = int(match[3])
509
+ else:
510
+ code = None
511
+ model = None
512
+ rev = None
513
+
514
+ # the VER part tells us the CURRENTLY RUNNING code, so it must identify as IAP if we're in IAP mode
515
+ is_iap = "IAP" in parts[2]
516
+ # the device type is allowed to have an additional identifying string appended after "-"
517
+ # this enables us to clearly distinguish between DEBUG/RELEASE builds
518
+ dev_type = parts[0].split("-")[0]
519
+
520
+ # greedy combiner for NAME -- i.e. if there are any unexpected spaces, consider it part of the NAME
521
+ name, _, serial = parts[3].rpartition(" ")
522
+ if not name:
523
+ name = serial
524
+
525
+ # construct a dict to return
526
+ self._dev_info = {
527
+ "type": dev_type,
528
+ "dev": "-DEV" in parts[0],
529
+ "serial": serial,
530
+ "name": name,
531
+ "ver": parts[2],
532
+ "code": code,
533
+ "model": model,
534
+ "rev": rev,
535
+ "iap": is_iap,
536
+ }
537
+ return self._dev_info
538
+
539
+ def title(self) -> str:
540
+ """
541
+ Return a string identifying the device type, name and serial for window title
542
+ """
543
+ if self._dev_info:
544
+ return " ".join({self['type'], self['name'], self['serial']})
545
+ else:
546
+ return "Not connected"
547
+
548
+ def versions(self) -> Dict[str, str]:
549
+ """
550
+ Query the dictionary of version numbers.
551
+ Keys are expected to be upper-case. Depending on the product, the keys may include:
552
+ "UC" = Version of the microcontroller firmware
553
+ "IAP" = Version of the bootloader firmware
554
+ "DISP" = Version of the integrated display firmware and menu system
555
+ "FPGA" = Version of the FPGA firmware
556
+ """
557
+ verstr = self.ask("version")
558
+ if verstr == "Command not defined":
559
+ raise RuntimeError("Incompatible firmware")
560
+ # does the version string define components?
561
+ versions = {}
562
+ if ":" in verstr:
563
+ # response might be comma-separated or LF-separated (legacy)
564
+ tk = "," if "," in verstr else "\n"
565
+ for line in verstr.split(tk):
566
+ if line.startswith("OK"):
567
+ continue
568
+ name, ver = line.split(":", 2)
569
+ if " " in ver:
570
+ ver = ver.rsplit(" ", 2)[1].strip()
571
+ versions[name.strip()] = ver
572
+ else:
573
+ # legacy: no dictionary, just the micro version
574
+ versions["UC"] = verstr.strip()
575
+ return versions
576
+
577
+ def cmd(self, cmd: str) -> str:
578
+ """Send the specified command string, and sanity-check that the response is OK."""
579
+ resp = self.ask(cmd)
580
+ if resp.startswith("OK"):
581
+ return resp
582
+ elif not resp.startswith("ERR"):
583
+ resp = f"Invalid response: {resp!r}"
584
+ raise DeviceError(resp, cmd, self)
585
+
586
+ def ask(self, cmd: DATA, allow_bytes: bool = False) -> DATA:
587
+ """
588
+ Query the device by sending a query string and receiving the response, and raising a DeviceError if the
589
+ response string is an error message.
590
+
591
+ @param cmd: Data to send to the device, either a `bytes` or `str` object.
592
+ @param allow_bytes: In most cases a `str` response is expected to the query, but in some cases it may be
593
+ preferable to allow returning a `bytes` instance if the reply cannot be decoded to unicode.
594
+ If False, failure to decode the response raises RuntimeError.
595
+
596
+ @raises DeviceError: The device responded to the query with an "ERR" string.
597
+ @raises RuntimeError: The response could not be decoded and `allow_bytes` was False.
598
+ """
599
+ # check the connection before trying to acquire the lock, to fail early
600
+ deadline = time.monotonic() + self._timeout
601
+ while time.monotonic() < deadline:
602
+ with self._locked():
603
+ # remove any response waiting on the line
604
+ self.flush()
605
+ # perform combination send and receive without releasing the mutex
606
+ self.send(cmd)
607
+ # NB: call underlying receive to get a bytes object
608
+ resp = self.recv().strip()
609
+ # check if the device was busy
610
+ if resp == b"ERR: Retry":
611
+ time.sleep(self._retry_delay)
612
+ continue
613
+ break
614
+ # check if the response indicates an error
615
+ if resp.startswith(b"ERR:"):
616
+ raise DeviceError(resp, cmd, self)
617
+ # if the query was a bytes object, return a bytes object
618
+ if isinstance(cmd, bytes):
619
+ return resp
620
+ # try to decode to unicode
621
+ try:
622
+ return resp.decode()
623
+ except UnicodeError as E:
624
+ if allow_bytes:
625
+ return resp
626
+ raise RuntimeError("Unexpected binary response") from E
627
+
628
+ def ask_dict(self, cmd: str) -> Dict[str, str]:
629
+ """Send a request which returns a dictionary response, with keys and values in Unicode"""
630
+ resp = self.ask(cmd)
631
+ # require a colon in there
632
+ if ":" not in resp:
633
+ raise ValueError("Not a dictionary")
634
+ # response could be comma-delimited or newline-delimited (but NOT CRLF)
635
+ splitchar = "\n" if "\n" in resp else ","
636
+ # construct the dict (retains original key order)
637
+ vals = {}
638
+ # we parse on the ':' first to allow the splitchar to be in the response
639
+ parts = resp.split(":")
640
+ key = parts[0].strip()
641
+ for part in parts[1:-1]:
642
+ val, _, remainder = part.rpartition(splitchar)
643
+ vals[key] = val.strip(splitchar + string.whitespace)
644
+ key = remainder.strip()
645
+ # special handling for the last key
646
+ vals[key] = parts[-1].strip(splitchar + string.whitespace)
647
+ if "" in vals:
648
+ raise ValueError("Not a dictionary")
649
+ return vals
650
+
651
+ def ask_bin(self, cmd: str) -> bytes:
652
+ """Send a request which returns a binary response package, returned as bytes"""
653
+ data = b""
654
+ datalen = -1
655
+ deadline = time.monotonic() + self._timeout
656
+ while time.monotonic() < deadline:
657
+ # Deliberately release the mutex lock between retries
658
+ with self._locked():
659
+ self.flush()
660
+ self.send(cmd)
661
+ head = self.recv_raw(4)
662
+ # is it an error message?
663
+ if head == b"ERR:":
664
+ # receive the rest of the message string, which is EOM-terminated
665
+ msg = self.recv().strip()
666
+ # the device is too busy to send the reply now, so try again
667
+ if msg == b"Retry":
668
+ time.sleep(self._retry_delay)
669
+ continue
670
+ raise DeviceError(msg, cmd, self)
671
+ # decode the length of the data packet
672
+ datalen = unpack("<L", head)[0]
673
+ # receive the payload
674
+ data = self.recv_raw(datalen)
675
+ break
676
+ if datalen < 0:
677
+ raise TimeoutError("timed out")
678
+ if len(data) != datalen:
679
+ raise RuntimeError("Incorrect length received")
680
+ return data
681
+
682
+ def ask_val(self, query: str, units: Optional[str] = None, type: callable = float):
683
+ """
684
+ Request a value from the device and convert the response, handling simple unit conversion if specified.
685
+ @param query: Query string to send to the device.
686
+ @param units: Optional string specifying the target units, for unit conversion (@see convert_measurement).
687
+ @param type: Callable specifying the return type.
688
+ """
689
+ return convert_measurement(self.ask(query), units, type=type)
690
+
691
+ def ask_list(self, query: str, units: Optional[str] = None, type: Optional[callable] = float) -> list:
692
+ """
693
+ Query a comma-separated list, and perform units conversion on each element if specified.
694
+ @param query: Query string to send to the device.
695
+ @param units: Optional string specifying the target units, for unit conversion (@see convert_measurement).
696
+ @param type: Optional callable specifying the return type.
697
+ If None, returns a list of `str` objects instead of converting each entry.
698
+ """
699
+ resp = self.ask(query)
700
+ # Permit a trailing comma
701
+ if resp.endswith(","):
702
+ resp = resp[:-1]
703
+ # Return the empty list for the trivial reply
704
+ if not resp:
705
+ return []
706
+ parts = resp.split(",")
707
+ if type is None or type is str:
708
+ # No type conversion, only remove spaces
709
+ return [item.strip() for item in parts]
710
+ else:
711
+ # Perform type conversion with default units
712
+ return [convert_measurement(item, units, units, type) for item in parts]
713
+
714
+ def send(self, cmd: DATA) -> None:
715
+ """Send a payload to the device, converting to `bytes` if necessary and appending newline if not present"""
716
+ with self._locked():
717
+ if not isinstance(cmd, bytes):
718
+ cmd = cmd.encode()
719
+ if not cmd.endswith(CRLF):
720
+ cmd += CRLF
721
+ self.logger.debug("-> %r", cmd)
722
+ self.dev.send(cmd)
723
+
724
+ def send_raw(self, data: bytes) -> None:
725
+ """Send specified bytes to the device, without modification"""
726
+ with self._locked():
727
+ return self.dev.send(data)
728
+
729
+ def flush(self, timeout: float = 0) -> bytes:
730
+ """Flush the connection by reading whatever data is pending to be received"""
731
+ with self._locked():
732
+ dat = b""
733
+ deadline = time.monotonic() + timeout
734
+ while self.dev.pending():
735
+ dat += self.dev.recv(-1)
736
+ if time.monotonic() > deadline:
737
+ break
738
+ if dat:
739
+ self.logger.debug("Flushed %d bytes", len(dat))
740
+ return dat
741
+
742
+ def recv(self) -> bytes:
743
+ """Receive a CRLF-terminated message from the device, returned as bytes"""
744
+ with self._locked():
745
+ data = b""
746
+ deadline = time.monotonic() + self._timeout
747
+ # Keep reading until there is no pending data, and the response ends in CRLF
748
+ while self.dev.pending() or not data.endswith(CRLF):
749
+ if time.monotonic() > deadline:
750
+ raise TimeoutError("timed out")
751
+ data += self.dev.recv(-1)
752
+ self.logger.debug("<- %r", data)
753
+ return data
754
+
755
+ def recv_raw(self, size: int) -> bytes:
756
+ """Receive exactly the specified number of bytes from the device"""
757
+ with self._locked():
758
+ data = b""
759
+ deadline = time.monotonic() + self._timeout
760
+ while len(data) < size:
761
+ if time.monotonic() > deadline:
762
+ raise TimeoutError("timed out")
763
+ data += self.dev.recv(size - len(data))
764
+ self.logger.debug("<- %d bytes", len(data))
765
+ return data
766
+
767
+ def get_timeout(self) -> float:
768
+ """Return the connection timeout, in seconds"""
769
+ if self.connected():
770
+ return self.dev.timeout
771
+ return self._timeout
772
+
773
+ def set_timeout(self, val: float) -> float:
774
+ """
775
+ Change the timeout to the specified value, in seconds.
776
+ Returns the previously-configured timeout, in seconds.
777
+ """
778
+ with self.lock:
779
+ old = self.get_timeout()
780
+ if self.connected():
781
+ self.dev.timeout = val
782
+ # Store the new timeout for reconnection attempts
783
+ self._timeout = val
784
+ return old
785
+
786
+
787
+ def load_script(filename) -> Generator[Tuple[int, str], None, None]:
788
+ """
789
+ Generator function that reads the lines from a text file and removes the comments.
790
+ Yields a pair of values containing the line number and string contents.
791
+ Skips empty lines, or lines that only contain comments.
792
+ """
793
+ with open(filename) as f: # open in universal mode
794
+ for linenum, rawline in enumerate(f):
795
+ # remove comments
796
+ line, _, _ = rawline.partition("#")
797
+ # trim spaces
798
+ line = line.strip()
799
+ # only yield non-trivial lines
800
+ if line:
801
+ yield linenum + 1, line
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqidevice
3
+ Version: 2.0.0
4
+ Summary: Python interface for communicating with Santec Quantum Instrument devices
5
+ Author: Santec Australia
6
+ License: MIT
7
+ License-File: LICENSE.txt
8
+ Keywords: ethernet,instrument,quantum,santec,usb
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Instrument Drivers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.8
18
+ Requires-Dist: pyserial>=3.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # SQIDevice: Simple interface to Santec Quantum Instruments
22
+
23
+ The `SQIDevice` class provides a simple interface for interacting with Santec Quantum Instruments over
24
+ a USB or ETH connection.
25
+
26
+ It handles end-of-message termination, concatenating multi-component responses, and converts error messages into exceptions to simplify error handling in the application.
27
+
28
+ Helper functions are provided for common operations, such as `ask_val()` for querying a numerical value and converting SI units, and `ask_list()` to transform comma-separated values into a `list`.
29
+
30
+
31
+ ## Overview
32
+
33
+ The `SQIDevice` class is instantiated with a string defining the connection instance, for example:
34
+ ```
35
+ # TCP connection by specifying IP address and optionally server port
36
+ SQIDevice("10.1.1.122")
37
+ SQIDevice("10.1.1.122:7802")
38
+ SQIDevice("10.1.1.122", port=7802)
39
+
40
+ # USB connection via virtual COM port
41
+ SQIDevice("COM3")
42
+ SQIDevice("USB", port=3)
43
+ ```
44
+
45
+ The primary functions for communicating with the device are:
46
+ * `ask(query)`: Send the provided `query` string and return the response as a string.
47
+ Intended as the main mechanism for _reading_ values from the device.
48
+ If `query` is bytes, the return value is also bytes.
49
+ * `ask_val(query)`: Calls `ask(query)` and performs type conversion on the response string; to `float` by default.
50
+ Also performs simple SI-units conversion of the response, when units are provided.
51
+ Recommended for querying measured values from the device, instead of type-casting the response string directly.
52
+ * `ask_dict(query)`: Calls `ask(query)` and parses the response string into a python dictionary.
53
+ Intended for parsing compound responses such as `VER` and `REPORT`.
54
+ Does not perform type conversion of the values.
55
+ * `ask_list(query)`: Calls `ask(query)` and parses the response as a comma-separated list of values.
56
+ Optionally performs unit conversion and type conversion.
57
+ * `cmd(command)`: Send the provided command string to the device and wait for a response.
58
+ Commands are different from queries in that they induce an action and hence are expected to respond with an `OK` string.
59
+ Intended to be used when _writing_ values to the device.
60
+ Failing to respond in this way raises a `DeviceError`.
61
+ * `reconnect()`: Close the connection (if applicable) and reconnect with the same parameters.
62
+ Recommended for handling disconnection or reboot events.
63
+
64
+
65
+ The queries and commands are product-specific, and can be found in the appropriate Appendix of the relevant product manual.
66
+
67
+
68
+
69
+ ## Properties
70
+ The `INFO` query is common across compatible devices to help identify the product at a glance.
71
+ The `SQIDevice` parses this query at connection and stores the results as a `dict` which can be accessed directly by indexing the device instance via `__getitem__()`.
72
+
73
+ The dictionary contains at least the following keys:
74
+ - `type`: Product name string (e.g. "DDLC" or "FZW").
75
+ - `rev`: Mainboard PCB revision, to help identify firmware compatibility.
76
+ - `ver`: Primary version numbers for the UC (and FPGA if applicable). See also the `VER` query for additional information.
77
+ - `serial`: Serial number of the unit.
78
+ - `name`: User-defined name for the unit, as set with the `DEVNAME` command, or the serial number when a custom name is not set.
79
+ - `iap`: Identifies that the devices is in firmware-update (IAP) mode.
80
+
81
+
82
+ ## Error handling
83
+ The command and query functions of the `SQIDevice` class raise exceptions to handle error scenarios, that should be caught at the application level.
84
+
85
+ ### DeviceError
86
+ A response was successfully received from the device, but that response is an error message.
87
+ This raises an instance of `DeviceError` containing both the request that failed and the response error message.
88
+
89
+ ### USBError
90
+ Particularly on the Windows(tm) operating system, the error messages raised by `pyserial` are often unintuitive and it is unclear how to resolve them.
91
+
92
+ The `USBError` class is a subclass of `OSError` that translates the most common error messages into a clearer form.
93
+
94
+ ### TimeoutError
95
+ Raised if no response was received, or an incomplete response was received before the timeout period elapsed.
96
+
97
+ ### OSError
98
+ Typically any error at the transport level will result in a subclass of `OSError` being raised by the underlying `socket` or `serial` class instances.
99
+ Usually this indicates that the device has been disconnected, rebooted, or powered off, and needs to be reconnected.
100
+
101
+ Examples include `TimeoutError` and `USBError`.
102
+
103
+ ### AssertionError
104
+ If an attempt is made to communicate with the device after closing the connection, it will cause `AssertionError` to be raised.
@@ -0,0 +1,6 @@
1
+ sqidevice/__init__.py,sha256=OxVVgspiHlG5Qc3-HSJXjqisyVG-ROVWGtcPLvP0GRY,370
2
+ sqidevice/sqidevice.py,sha256=d88cKGKuR4lYBMzkVx3DEogVXn_VAJCQUJhhms8JdwM,32846
3
+ sqidevice-2.0.0.dist-info/METADATA,sha256=t5V8b35YanJwgCW1LBOcFGR1I6AwIyyem0THsamhGVI,5421
4
+ sqidevice-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ sqidevice-2.0.0.dist-info/licenses/LICENSE.txt,sha256=l3dWfXLHqaVlRK3vE5OiMGXf4wBTwecRHm22Poy280Q,1086
6
+ sqidevice-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2017 - present, Santec Australia Pty Ltd.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE