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,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
|