wasatch 2.3.2__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.
- wasatch/AbstractUSBDevice.py +29 -0
- wasatch/AndorDevice.py +910 -0
- wasatch/AreaScanImage.py +19 -0
- wasatch/AutoRaman.py +457 -0
- wasatch/AutoRamanRequest.py +96 -0
- wasatch/BLEDevice.py +1032 -0
- wasatch/BalanceAcquisition.py +136 -0
- wasatch/CSVLoader.py +188 -0
- wasatch/CommandSettings.py +95 -0
- wasatch/ControlObject.py +17 -0
- wasatch/DetectorROI.py +36 -0
- wasatch/DetectorRegions.py +158 -0
- wasatch/DeviceFinderBLE.py +107 -0
- wasatch/DeviceFinderTCP.py +20 -0
- wasatch/DeviceFinderUSB.py +236 -0
- wasatch/DeviceID.py +344 -0
- wasatch/EEPROM.py +1450 -0
- wasatch/EEPROM_multical.sav +48 -0
- wasatch/FPGAOptions.py +125 -0
- wasatch/FeatureIdentificationDevice.py +3961 -0
- wasatch/FirmwareRequirements.py +76 -0
- wasatch/HardwareInfo.py +36 -0
- wasatch/IDSCamera.py +916 -0
- wasatch/IDSDevice.py +329 -0
- wasatch/IMX385.py +179 -0
- wasatch/InterfaceDevice.py +40 -0
- wasatch/MockUSBDevice.py +353 -0
- wasatch/OceanDevice.py +215 -0
- wasatch/PollStatus.py +15 -0
- wasatch/ProcessedReading.py +352 -0
- wasatch/ROI.py +33 -0
- wasatch/Reading.py +80 -0
- wasatch/RealUSBDevice.py +64 -0
- wasatch/SPIDevice.py +628 -0
- wasatch/SpectrometerRequest.py +15 -0
- wasatch/SpectrometerResponse.py +38 -0
- wasatch/SpectrometerSettings.py +446 -0
- wasatch/SpectrometerState.py +255 -0
- wasatch/StatusMessage.py +14 -0
- wasatch/TCPDevice.py +409 -0
- wasatch/TakeOneRequest.py +43 -0
- wasatch/WasatchBus.py +87 -0
- wasatch/WasatchDevice.py +1060 -0
- wasatch/WasatchDeviceWrapper.py +452 -0
- wasatch/WrapperWorker.py +304 -0
- wasatch/__init__.py +4 -0
- wasatch/applog.py +177 -0
- wasatch/simulation_protocol.py +227 -0
- wasatch/utils.py +731 -0
- wasatch-2.3.2.dist-info/LICENSE +21 -0
- wasatch-2.3.2.dist-info/METADATA +347 -0
- wasatch-2.3.2.dist-info/RECORD +53 -0
- wasatch-2.3.2.dist-info/WHEEL +4 -0
wasatch/SPIDevice.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import usb
|
|
3
|
+
import time
|
|
4
|
+
import logging
|
|
5
|
+
import datetime
|
|
6
|
+
import platform
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from queue import Queue
|
|
10
|
+
|
|
11
|
+
from pyftdi.ftdi import FtdiError
|
|
12
|
+
|
|
13
|
+
# Needed for Mac side
|
|
14
|
+
# Device Finder should already have done this
|
|
15
|
+
# For thoroughness though doing here anyway
|
|
16
|
+
# This is required for finding the usb <-> serial board
|
|
17
|
+
#
|
|
18
|
+
# MZ: I don't remember anything about this, but SPI is not a priority at
|
|
19
|
+
# this time and this broke under Python 3.11
|
|
20
|
+
#
|
|
21
|
+
import usb.core
|
|
22
|
+
# usb.core.find()
|
|
23
|
+
|
|
24
|
+
from .SpectrometerSettings import SpectrometerSettings
|
|
25
|
+
from .SpectrometerState import SpectrometerState
|
|
26
|
+
from .SpectrometerResponse import SpectrometerResponse
|
|
27
|
+
from .SpectrometerResponse import ErrorLevel
|
|
28
|
+
from .DeviceID import DeviceID
|
|
29
|
+
from .InterfaceDevice import InterfaceDevice
|
|
30
|
+
from .Reading import Reading
|
|
31
|
+
from .EEPROM import EEPROM
|
|
32
|
+
|
|
33
|
+
import crcmod.predefined
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @note write_len includes the opcode (address) itself, so a 1-byte value like
|
|
39
|
+
# PixelMode has write_len 2, and a 24-bit value like Integration Time has
|
|
40
|
+
# write_len 4.
|
|
41
|
+
class CommandTuple:
|
|
42
|
+
def __init__(self, address, value, write_len, name):
|
|
43
|
+
self.address = address
|
|
44
|
+
self.value = value
|
|
45
|
+
self.write_len = write_len
|
|
46
|
+
self.name = name
|
|
47
|
+
|
|
48
|
+
def __str__(self):
|
|
49
|
+
return f"CommandTuple <name {self.name}, address 0x{self.address:02x}, value {self.value}, write_len {self.write_len}>"
|
|
50
|
+
|
|
51
|
+
class SPIDevice(InterfaceDevice):
|
|
52
|
+
"""
|
|
53
|
+
This implements SPI communication via the FT232H usb converter.
|
|
54
|
+
|
|
55
|
+
This class adopts the external device interface structure.
|
|
56
|
+
This involves receiving a request through the handle_request function.
|
|
57
|
+
A request is processed based on the key in the request.
|
|
58
|
+
The processing function passes the commands to the requested device.
|
|
59
|
+
Once it receives a response from the connected device it then passes that
|
|
60
|
+
back up the chain.
|
|
61
|
+
|
|
62
|
+
@verbatim
|
|
63
|
+
Enlighten Request
|
|
64
|
+
|
|
|
65
|
+
handle_requests
|
|
66
|
+
|
|
|
67
|
+
------------
|
|
68
|
+
/ / | \ \
|
|
69
|
+
{ get_laser status, acquire, set_laser_watchdog, etc....}
|
|
70
|
+
\ \ | / /
|
|
71
|
+
------------
|
|
72
|
+
|
|
|
73
|
+
{self.driver.some_spi_call}
|
|
74
|
+
@endverbatim
|
|
75
|
+
|
|
76
|
+
@see https://github.com/WasatchPhotonics/Python-USB-WP-Raman-Examples/blob/master/SPI/spi_console.py
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
READ_RESPONSE_OVERHEAD = 5 # <, LEN_MSB, LEN_LSB, CRC, > # does NOT include ADDR
|
|
80
|
+
WRITE_RESPONSE_OVERHEAD = 2 # <, >
|
|
81
|
+
READY_POLL_LEN = 2 # 1 seems to work
|
|
82
|
+
START = 0x3c # <
|
|
83
|
+
END = 0x3e # >
|
|
84
|
+
WRITE = 0x80 # bit changing opcodes from 'getter' to 'setter'
|
|
85
|
+
CRC = 0xff # for readability
|
|
86
|
+
|
|
87
|
+
lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def __init__(self, device_id, message_queue=None, alert_queue=None):
|
|
90
|
+
super().__init__()
|
|
91
|
+
|
|
92
|
+
########################################################################
|
|
93
|
+
# Attempt to import dependencies (connects to FT232H)
|
|
94
|
+
########################################################################
|
|
95
|
+
|
|
96
|
+
import_successful = False
|
|
97
|
+
try:
|
|
98
|
+
os.environ["BLINKA_FT232H"] = "1"
|
|
99
|
+
import board
|
|
100
|
+
import digitalio
|
|
101
|
+
import busio
|
|
102
|
+
import_successful = True
|
|
103
|
+
except RuntimeError:
|
|
104
|
+
log.error("No FT232H connected.", exc_info=1)
|
|
105
|
+
if platform.system() == "Windows":
|
|
106
|
+
log.error("Ensure you've followed the Zadig process in README_SPI.md")
|
|
107
|
+
except ValueError:
|
|
108
|
+
log.error("If you are receiving 'no backend available' errors, try the following:")
|
|
109
|
+
log.error("MacOS: $ export DYLD_LIBRARY_PATH=/usr/local/lib")
|
|
110
|
+
log.error("Linux: $ export LD_LIBRARY_PATH=/usr/local/lib")
|
|
111
|
+
except FtdiError:
|
|
112
|
+
log.error("No FT232H connected.", exc_info=1)
|
|
113
|
+
if platform.system() == "Windows":
|
|
114
|
+
log.error("Ensure you've followed the Zadig process in README_SPI.md")
|
|
115
|
+
log.error("SPIDevice is not usable")
|
|
116
|
+
if not import_successful:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self.crc8 = crcmod.predefined.mkPredefinedCrcFun('crc-8-maxim')
|
|
120
|
+
|
|
121
|
+
########################################################################
|
|
122
|
+
# Proceed with initialization
|
|
123
|
+
########################################################################
|
|
124
|
+
|
|
125
|
+
# if passed a string representation of a DeviceID, deserialize it
|
|
126
|
+
if type(device_id) is str:
|
|
127
|
+
device_id = DeviceID(label=device_id)
|
|
128
|
+
|
|
129
|
+
self.device_id = device_id
|
|
130
|
+
self.message_queue = message_queue
|
|
131
|
+
self.alert_queue = alert_queue
|
|
132
|
+
|
|
133
|
+
self.connected = False
|
|
134
|
+
self.disconnect = False
|
|
135
|
+
self.acquiring = False
|
|
136
|
+
|
|
137
|
+
# Receives ENLIGHTEN's 'change settings' commands in the spectrometer
|
|
138
|
+
# process. Although a logical queue, has nothing to do with multiprocessing.
|
|
139
|
+
self.command_queue = []
|
|
140
|
+
|
|
141
|
+
self.immediate_mode = False
|
|
142
|
+
|
|
143
|
+
self.settings = SpectrometerSettings(self.device_id)
|
|
144
|
+
self.summed_spectra = None
|
|
145
|
+
self.sum_count = 0
|
|
146
|
+
self.session_reading_count = 0
|
|
147
|
+
self.take_one = False
|
|
148
|
+
self.failure_count = 0
|
|
149
|
+
|
|
150
|
+
self.process_id = os.getpid()
|
|
151
|
+
self.last_memory_check = datetime.datetime.now()
|
|
152
|
+
self.last_battery_percentage = 0
|
|
153
|
+
self.lambdas = None
|
|
154
|
+
self.spec_index = 0
|
|
155
|
+
self._scan_averaging = 1
|
|
156
|
+
self.dark = None
|
|
157
|
+
self.boxcar_half_width = 0
|
|
158
|
+
|
|
159
|
+
# Initialize the SPI bus on the FT232H
|
|
160
|
+
self.SPI = busio.SPI(clock=board.SCK, MISO=board.MISO, MOSI=board.MOSI)
|
|
161
|
+
|
|
162
|
+
# Initialize D5 as the ready signal (allow override)
|
|
163
|
+
self.ready = digitalio.DigitalInOut(getattr(board, os.getenv("SPI_PIN_READY", default="D5")))
|
|
164
|
+
self.ready.direction = digitalio.Direction.INPUT
|
|
165
|
+
|
|
166
|
+
# Initialize D6 as the trigger (allow override)
|
|
167
|
+
self.trigger = digitalio.DigitalInOut(getattr(board, os.getenv("SPI_PIN_TRIGGER", default="D6")))
|
|
168
|
+
self.trigger.direction = digitalio.Direction.OUTPUT
|
|
169
|
+
self.trigger.value = False
|
|
170
|
+
|
|
171
|
+
# Take control of the SPI Bus
|
|
172
|
+
while not self.SPI.try_lock():
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
# Configure the SPI bus (allow override)
|
|
176
|
+
self.baud_mhz = int(os.getenv("SPI_BAUD_MHZ", default="10"))
|
|
177
|
+
log.debug(f"using baud rate {self.baud_mhz}MHz")
|
|
178
|
+
self.SPI.configure(baudrate=self.baud_mhz * 1e6, phase=0, polarity=0, bits=8)
|
|
179
|
+
|
|
180
|
+
# for kicks, let block size be overridden by the environment
|
|
181
|
+
self.block_size = int(os.getenv("SPI_BLOCK_SIZE", default="256"))
|
|
182
|
+
log.debug(f"using SPI block size {self.block_size}")
|
|
183
|
+
|
|
184
|
+
self.process_f = self._init_process_funcs()
|
|
185
|
+
|
|
186
|
+
########################################################################
|
|
187
|
+
# Store SPI command table in a lookup
|
|
188
|
+
########################################################################
|
|
189
|
+
|
|
190
|
+
self.cmds = {}
|
|
191
|
+
for addr, value, len_, name in [
|
|
192
|
+
#---- ----- ---- -----------
|
|
193
|
+
(0x11, 3, 4, "Integration Time"),
|
|
194
|
+
(0x13, 0, 3, "Black Level"),
|
|
195
|
+
(0x14, 24, 3, "Gain dB"),
|
|
196
|
+
(0x2B, 3, 2, "Pixel Mode"),
|
|
197
|
+
(0x50, 250, 3, "Start Line 0"),
|
|
198
|
+
(0x51, 750, 3, "Stop Line 0"),
|
|
199
|
+
(0x52, 12, 3, "Start Column 0"),
|
|
200
|
+
(0x53, 1932, 3, "Stop Column 0"),
|
|
201
|
+
(0x54, 0, 3, "Start Line 1"),
|
|
202
|
+
(0x55, 0, 3, "Stop Line 1"),
|
|
203
|
+
(0x56, 0, 3, "Start Column 1"),
|
|
204
|
+
(0x57, 0, 3, "Stop Column 1"),
|
|
205
|
+
(0x58, 0, 3, "Desmile") ]:
|
|
206
|
+
self.cmds[name] = CommandTuple(addr, value, len_, name)
|
|
207
|
+
|
|
208
|
+
# patch gain
|
|
209
|
+
cmd = self.cmds["Gain dB"]
|
|
210
|
+
cmd.value = self.gain_to_ff(cmd.value)
|
|
211
|
+
|
|
212
|
+
def connect(self): # -> SpectrometerResponse
|
|
213
|
+
log.debug("initializing EEPROM")
|
|
214
|
+
if not self.init_eeprom():
|
|
215
|
+
log.critical("failed to initialize EEPROM, giving up")
|
|
216
|
+
return SpectrometerResponse(False, error_msg="EEPROM initialization failure", error_lvl=ErrorLevel.high, poison_pill=True)
|
|
217
|
+
|
|
218
|
+
# initialize all settings -- this ensures no setting is uninitialized
|
|
219
|
+
log.debug("initializing all commands")
|
|
220
|
+
for key in self.cmds:
|
|
221
|
+
cmd = self.cmds[key]
|
|
222
|
+
log.debug(f"initializing {cmd}")
|
|
223
|
+
self.send_command(cmd)
|
|
224
|
+
|
|
225
|
+
self.settings.state.integration_time_ms = self.settings.eeprom.startup_integration_time_ms
|
|
226
|
+
self.settings.state.gain_db = self.settings.eeprom.detector_gain
|
|
227
|
+
|
|
228
|
+
log.info("SPI connect done, returning True")
|
|
229
|
+
return SpectrometerResponse(True)
|
|
230
|
+
|
|
231
|
+
## @returns True on success
|
|
232
|
+
def init_eeprom(self): # -> bool
|
|
233
|
+
eeprom = self.settings.eeprom
|
|
234
|
+
|
|
235
|
+
pages = []
|
|
236
|
+
for i in range(EEPROM.MAX_PAGES):
|
|
237
|
+
log.debug(f"flushing buffer before page {i}")
|
|
238
|
+
if not self.flush_input_buffer():
|
|
239
|
+
log.error("unable to read EEPROM")
|
|
240
|
+
return False
|
|
241
|
+
pages.append(self.read_page(i))
|
|
242
|
+
|
|
243
|
+
if not eeprom.parse(pages):
|
|
244
|
+
log.error(f"failed to parse EEPROM")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
self.settings.update_wavecal()
|
|
248
|
+
self.settings.update_raman_intensity_factors()
|
|
249
|
+
|
|
250
|
+
# copy startup values from EEPROM into our local command table so they
|
|
251
|
+
# get initialized properly
|
|
252
|
+
|
|
253
|
+
self.cmds["Integration Time"].value = eeprom.startup_integration_time_ms
|
|
254
|
+
self.cmds["Gain dB" ].value = self.gain_to_ff(eeprom.detector_gain)
|
|
255
|
+
self.cmds["Start Line 0" ].value = eeprom.roi_vertical_region_1_start
|
|
256
|
+
self.cmds["Stop Line 0" ].value = eeprom.roi_vertical_region_1_end
|
|
257
|
+
self.cmds["Start Column 0" ].value = eeprom.multi_wavelength_calibration.get("roi_horizontal_start")
|
|
258
|
+
self.cmds["Stop Column 0" ].value = eeprom.multi_wavelength_calibration.get("roi_horizontal_end")
|
|
259
|
+
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
def disconnect(self): # -> SpectrometerResponse
|
|
263
|
+
self.disconnect = True
|
|
264
|
+
return SpectrometerResponse(True)
|
|
265
|
+
|
|
266
|
+
def acquire_data(self): # -> SpectrometerResponse
|
|
267
|
+
log.debug("spi starts reading")
|
|
268
|
+
if self.disconnect:
|
|
269
|
+
log.debug("disconnecting, returning False for the spectrum")
|
|
270
|
+
return False
|
|
271
|
+
averaging_enabled = (self.settings.state.scans_to_average > 1)
|
|
272
|
+
reading = Reading(self.device_id)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
reading.integration_time_ms = self.settings.state.integration_time_ms
|
|
276
|
+
reading.laser_power_perc = self.settings.state.laser_power_perc
|
|
277
|
+
reading.laser_power_mW = self.settings.state.laser_power_mW
|
|
278
|
+
reading.laser_enabled = self.settings.state.laser_enabled
|
|
279
|
+
reading.spectrum = self.get_spectrum()
|
|
280
|
+
if not reading.spectrum:
|
|
281
|
+
return False
|
|
282
|
+
except usb.USBError:
|
|
283
|
+
self.failure_count += 1
|
|
284
|
+
log.error(f"SPI Device: encountered USB error in reading for device {self.device}")
|
|
285
|
+
|
|
286
|
+
if not reading.failure:
|
|
287
|
+
if averaging_enabled:
|
|
288
|
+
if self.sum_count == 0:
|
|
289
|
+
self.summed_spectra = [float(i) for i in reading.spectrum]
|
|
290
|
+
else:
|
|
291
|
+
log.debug("device.take_one_averaged_reading: summing spectra")
|
|
292
|
+
for i in range(len(self.summed_spectra)):
|
|
293
|
+
self.summed_spectra[i] += reading.spectrum[i]
|
|
294
|
+
self.sum_count += 1
|
|
295
|
+
log.debug("device.take_one_averaged_reading: summed_spectra : %s ...", self.summed_spectra[0:9])
|
|
296
|
+
|
|
297
|
+
if self.settings.eeprom.horiz_binning_enabled:
|
|
298
|
+
# perform the 2x2 bin software side
|
|
299
|
+
next_idx_values = reading.spectrum[1:]
|
|
300
|
+
# average all except the last value, which is just appended as is
|
|
301
|
+
binned = [(value + next_value)/2 for value, next_value in zip(reading.spectrum[:-1], next_idx_values)]
|
|
302
|
+
binned.append(reading.spectrum[-1])
|
|
303
|
+
reading.spectrum = binned
|
|
304
|
+
|
|
305
|
+
self.session_reading_count += 1
|
|
306
|
+
reading.session_count = self.session_reading_count
|
|
307
|
+
reading.sum_count = self.sum_count
|
|
308
|
+
|
|
309
|
+
if averaging_enabled:
|
|
310
|
+
if self.sum_count >= self.settings.state.scans_to_average:
|
|
311
|
+
reading.spectrum = [ x / self.sum_count for x in self.summed_spectra ]
|
|
312
|
+
log.debug("spi device acquire data: averaged_spectrum : %s ...", reading.spectrum[0:9])
|
|
313
|
+
reading.averaged = True
|
|
314
|
+
|
|
315
|
+
# reset for next average
|
|
316
|
+
self.summed_spectra = None
|
|
317
|
+
self.sum_count = 0
|
|
318
|
+
else:
|
|
319
|
+
# if averaging isn't enabled...then a single reading is the
|
|
320
|
+
# "averaged" final measurement (check reading.sum_count to confirm)
|
|
321
|
+
reading.averaged = True
|
|
322
|
+
|
|
323
|
+
return SpectrometerResponse(reading)
|
|
324
|
+
|
|
325
|
+
def set_integration_time_ms(self, value: int): # -> SpectrometerResponse
|
|
326
|
+
cmd = self.cmds["Integration Time"]
|
|
327
|
+
cmd.value = value
|
|
328
|
+
self.send_command(cmd)
|
|
329
|
+
self.settings.state.integration_time_ms = value
|
|
330
|
+
return SpectrometerResponse()
|
|
331
|
+
|
|
332
|
+
def set_gain(self, value: float): # -> SpectrometerResponse
|
|
333
|
+
cmd = self.cmds["Gain dB"]
|
|
334
|
+
cmd.value = self.gain_to_ff(value)
|
|
335
|
+
self.send_command(cmd)
|
|
336
|
+
self.settings.state.gain_db = value
|
|
337
|
+
return SpectrometerResponse()
|
|
338
|
+
|
|
339
|
+
def change_setting(self,setting,value):
|
|
340
|
+
log.info(f"spi being told to change setting {setting} to {value}")
|
|
341
|
+
f = self.lambdas.get(setting,None)
|
|
342
|
+
if f is not None:
|
|
343
|
+
f(value)
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
def _init_process_funcs(self): # -> dict[str, Callable[..., Any]]
|
|
347
|
+
process_f = {}
|
|
348
|
+
|
|
349
|
+
process_f["connect"] = self.connect
|
|
350
|
+
process_f["disconnect"] = self.disconnect
|
|
351
|
+
process_f["acquire_data"] = self.acquire_data
|
|
352
|
+
process_f["set_integration_time_ms"] = self.set_integration_time_ms
|
|
353
|
+
process_f["detector_gain"] = self.set_gain
|
|
354
|
+
##################################################################
|
|
355
|
+
# What follows is the old init-lambdas that are squashed into process_f
|
|
356
|
+
# Long term, the upstream requests should be changed to match the new format
|
|
357
|
+
# This is an easy fix for the time being to make things behave
|
|
358
|
+
##################################################################
|
|
359
|
+
#process_f["write_eeprom"] = lambda x: self.write_eeprom()
|
|
360
|
+
#process_f["replace_eeprom"] = lambda x: self.write_eeprom()
|
|
361
|
+
process_f["integration_time_ms"] = lambda x: self.set_integration_time_ms(x)
|
|
362
|
+
|
|
363
|
+
return process_f
|
|
364
|
+
|
|
365
|
+
############################################################################
|
|
366
|
+
# #
|
|
367
|
+
# Utility Methods #
|
|
368
|
+
# #
|
|
369
|
+
############################################################################
|
|
370
|
+
|
|
371
|
+
## format a list or bytearray as "[ 0x00, 0x0a, 0xff ]"
|
|
372
|
+
def to_hex(self, values):
|
|
373
|
+
return "[ " + ", ".join([ f"0x{v:02x}" for v in values ]) + " ]"
|
|
374
|
+
|
|
375
|
+
## confirm the received CRC matches our computed CRC for the list or bytearray "data"
|
|
376
|
+
def check_crc(self, crc_received, data):
|
|
377
|
+
crc_computed = self.crc8(data)
|
|
378
|
+
if crc_computed != crc_received:
|
|
379
|
+
log.error(f"CRC mismatch: received 0x{crc_received:02x}, computed 0x{crc_computed:02x}")
|
|
380
|
+
|
|
381
|
+
## given a list or bytearray of data elements, return the checksum
|
|
382
|
+
def compute_crc(self, data):
|
|
383
|
+
return self.crc8(bytearray(data))
|
|
384
|
+
|
|
385
|
+
##
|
|
386
|
+
# given a formatted SPI command of the form [START, L0, L1, ADDR, ...DATA..., CRC, END],
|
|
387
|
+
# return command with CRC replaced with the computed checksum of [L0..DATA] as bytearray
|
|
388
|
+
def fix_crc(self, cmd):
|
|
389
|
+
if cmd is None or len(cmd) < 6 or cmd[0] != self.START or cmd[-1] != self.END:
|
|
390
|
+
log.error(f"fix_crc expects well-formatted SPI 'write' command: {cmd}")
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
index = len(cmd) - 2
|
|
394
|
+
checksum = self.compute_crc(bytearray(cmd[1:index]))
|
|
395
|
+
result = cmd[:index]
|
|
396
|
+
result.extend([checksum, cmd[-1]])
|
|
397
|
+
# log.debug(f"fix_crc: cmd {self.to_hex(cmd)} -> result {self.to_hex(result)}")
|
|
398
|
+
return bytearray(result)
|
|
399
|
+
|
|
400
|
+
## @see ENG-0150-C section 3.2, "Configuration Set Response Packet"
|
|
401
|
+
def errorcode_to_string(self, code): # -> str
|
|
402
|
+
if code == 0: return "SUCCESS"
|
|
403
|
+
elif code == 1: return "ERROR_LENGTH"
|
|
404
|
+
elif code == 2: return "ERROR_CRC"
|
|
405
|
+
elif code == 3: return "ERROR_UNRECOGNIZED_COMMAND"
|
|
406
|
+
else : return "ERROR_UNDEFINED"
|
|
407
|
+
|
|
408
|
+
## @param response (Input): the last 3 bytes of the device's response to a SPI write command
|
|
409
|
+
def validate_write_response(self, response): # -> str
|
|
410
|
+
if len(response) != 3:
|
|
411
|
+
return f"invalid response length: {response}"
|
|
412
|
+
if response[0] != self.START:
|
|
413
|
+
return f"invalid response START marker: {response}"
|
|
414
|
+
if response[2] != self.END:
|
|
415
|
+
return f"invalid response END marker: {response}"
|
|
416
|
+
return self.errorcode_to_string(response[1])
|
|
417
|
+
|
|
418
|
+
##
|
|
419
|
+
# Given an existing list or bytearray, copy the contents into a new bytearray of
|
|
420
|
+
# the specified size. This is used to generate the "command" argument of a
|
|
421
|
+
# SPI.write_readinto(cmd, response) call, as both buffers are expected to be of
|
|
422
|
+
# the same size.
|
|
423
|
+
#
|
|
424
|
+
# @see https://docs.circuitpython.org/en/latest/shared-bindings/busio/#busio.SPI.write_readinto
|
|
425
|
+
def buffer_bytearray(self, orig, size):
|
|
426
|
+
new = bytearray(size)
|
|
427
|
+
new[:len(orig)] = orig[:]
|
|
428
|
+
return new
|
|
429
|
+
|
|
430
|
+
##
|
|
431
|
+
# Given an unbuffered "read" command (just the bytes we wanted to write, without
|
|
432
|
+
# trailing zeros for the read response), and the complete (buffered) response
|
|
433
|
+
# read back (including leading junk from the command/write phase), parse out the
|
|
434
|
+
# actual response data and validate checksum.
|
|
435
|
+
#
|
|
436
|
+
# @par Example (reading FPGA version number)
|
|
437
|
+
# @verbatim
|
|
438
|
+
# offset: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
|
439
|
+
# explanation: < (_length_) ADDR > < (_length_) ADDR '0' '2' '.' '1' '.' '2' '3' CRC >
|
|
440
|
+
# unbuffered_command: [ 0x3c, 0x00, 0x01, 0x10, 0x3e ]
|
|
441
|
+
# buffered_command: [ 0x3c, 0x00, 0x01, 0x10, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ]
|
|
442
|
+
# buffered_response: [ ?? , ?? , ?? , ?? , ?? , 0x3c, 0x00, 0x08, 0x10, 0x30, 0x32, 0x2e, 0x31, 0x2e, 0x32, 0x33, 0x83, 0x3e ]
|
|
443
|
+
# unbuffered_response: [ 0x3c, 0x00, 0x08, 0x10, 0x30, 0x32, 0x2e, 0x31, 0x2e, 0x32, 0x33, 0x83, 0x3e ]
|
|
444
|
+
# crc_data: [ 0x00, 0x08, 0x10, 0x30, 0x32, 0x2e, 0x31, 0x2e, 0x32, 0x33 ]
|
|
445
|
+
# response_data: [ 0x30, 0x32, 0x2e, 0x31, 0x2e, 0x32, 0x33 ]
|
|
446
|
+
# @endverbatim
|
|
447
|
+
#
|
|
448
|
+
# @returns array of response payload bytes (everything after ADDR but before CRC)
|
|
449
|
+
# @note only used for SPI "read" commands ("write" commands are much simpler)
|
|
450
|
+
#
|
|
451
|
+
# @param unbuffered_cmd: the START..END read command sent to the device (but
|
|
452
|
+
# not the empty "buffer" bytes at the end, written while reading the response)
|
|
453
|
+
# @param buffered_response: the complete sequence of bytes read from the
|
|
454
|
+
# device, including the echoed unbuffered command
|
|
455
|
+
# @param name: for debugging
|
|
456
|
+
# @param missing_echo_len: in proper SPI, if I write 5 bytes, with an
|
|
457
|
+
# expected response of 13 bytes, then I should actually read 18 bytes:
|
|
458
|
+
# the "echoes" of the 5 written bytes, then the 13 actual response
|
|
459
|
+
# bytes. Buggy FW may lead to some "echo" bytes missing from the
|
|
460
|
+
# response stream; this value can allow the client to stay in sync.
|
|
461
|
+
# In the current design, this occurs when reading EEPROM pages.
|
|
462
|
+
def decode_read_response(self, unbuffered_cmd, buffered_response, name=None, missing_echo_len=0):
|
|
463
|
+
cmd_len = len(unbuffered_cmd)
|
|
464
|
+
unbuffered_response = buffered_response[len(unbuffered_cmd) - missing_echo_len:]
|
|
465
|
+
response_data_len = (unbuffered_response[1] << 8) | unbuffered_response[2]
|
|
466
|
+
response_data = unbuffered_response[4 : 4 + response_data_len - 1]
|
|
467
|
+
crc_received = unbuffered_response[-2]
|
|
468
|
+
crc_data = unbuffered_response[1 : -2]
|
|
469
|
+
self.check_crc(crc_received, crc_data)
|
|
470
|
+
|
|
471
|
+
if True:
|
|
472
|
+
log.debug(f"decode_read_response({name}, missing={missing_echo_len}):")
|
|
473
|
+
log.debug(f" unbuffered_cmd: {self.to_hex(unbuffered_cmd)}")
|
|
474
|
+
log.debug(f" buffered_response: {self.to_hex(buffered_response)}")
|
|
475
|
+
log.debug(f" cmd_len: {cmd_len}")
|
|
476
|
+
log.debug(f" unbuffered_response: {self.to_hex(unbuffered_response)}")
|
|
477
|
+
log.debug(f" response_data_len: {response_data_len}")
|
|
478
|
+
log.debug(f" response_data: {self.to_hex(response_data)}")
|
|
479
|
+
log.debug(f" crc_received: {hex(crc_received)}")
|
|
480
|
+
log.debug(f" crc_data: {self.to_hex(crc_data)}")
|
|
481
|
+
|
|
482
|
+
return response_data
|
|
483
|
+
|
|
484
|
+
def send_command(self, cmd: CommandTuple):
|
|
485
|
+
txData = []
|
|
486
|
+
txData .append( cmd.value & 0xff) # LSB
|
|
487
|
+
if cmd.write_len > 2:
|
|
488
|
+
txData.append((cmd.value >> 8) & 0xff)
|
|
489
|
+
if cmd.write_len > 3:
|
|
490
|
+
txData.append((cmd.value >> 16) & 0xff) # MSB
|
|
491
|
+
|
|
492
|
+
unbuffered_cmd = [self.START, 0x00, cmd.write_len, cmd.address | self.WRITE]
|
|
493
|
+
unbuffered_cmd.extend(txData)
|
|
494
|
+
unbuffered_cmd.extend([self.compute_crc(unbuffered_cmd[1:]), self.END])
|
|
495
|
+
|
|
496
|
+
# MZ: the -1 at the end was added as a kludge, because otherwise we find
|
|
497
|
+
# a redundant '>' in the last byte. This seems a bug, due to the
|
|
498
|
+
# fact that only 7 of the 8 unbuffered_cmd bytes are echoed back into
|
|
499
|
+
# the read buffer.
|
|
500
|
+
buffered_response = bytearray(len(unbuffered_cmd) + self.WRITE_RESPONSE_OVERHEAD + 1 - 1)
|
|
501
|
+
buffered_cmd = self.buffer_bytearray(unbuffered_cmd, len(buffered_response))
|
|
502
|
+
|
|
503
|
+
with self.lock:
|
|
504
|
+
if not self.flush_input_buffer():
|
|
505
|
+
log.critical(f"failed to send command {cmd}")
|
|
506
|
+
return
|
|
507
|
+
self.SPI.write_readinto(buffered_cmd, buffered_response)
|
|
508
|
+
|
|
509
|
+
error_msg = self.validate_write_response(buffered_response[-3:])
|
|
510
|
+
log.debug(f"send_command[{cmd.name}]: {self.to_hex(buffered_cmd)} -> {self.to_hex(buffered_response)} ({error_msg})")
|
|
511
|
+
|
|
512
|
+
## @returns False if SPI communication hosed. This can happen for instance if your SPI_PIN_READY isn't set.
|
|
513
|
+
def flush_input_buffer(self): # -> bool
|
|
514
|
+
count = 0
|
|
515
|
+
junk = bytearray(self.READY_POLL_LEN)
|
|
516
|
+
|
|
517
|
+
MAX_READ = 4096 # something is plainly wrong
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
while self.ready.value:
|
|
521
|
+
self.SPI.readinto(junk)
|
|
522
|
+
count += self.READY_POLL_LEN
|
|
523
|
+
if count > MAX_READ:
|
|
524
|
+
log.critical(f"flush_input_buffer: giving up after flushing {count} bytes")
|
|
525
|
+
return False
|
|
526
|
+
except OSError:
|
|
527
|
+
log.critical("flush_input_buffer: OSError", exc_info=1)
|
|
528
|
+
return False
|
|
529
|
+
except FtdiError:
|
|
530
|
+
log.critical("flush_input_buffer: FtdiError", exc_info=1)
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
if count > 0:
|
|
534
|
+
log.debug(f"flushed {count} bytes from input buffer")
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
def wait_for_data_ready(self):
|
|
538
|
+
while not self.ready.value:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
##
|
|
542
|
+
# Convert a (potentially) floating-point value into the big-endian 16-bit "Funky
|
|
543
|
+
# Float" used for detector gain in the FPGA on both Hamamatsu and IMX sensors.
|
|
544
|
+
#
|
|
545
|
+
# @see https://wasatchphotonics.com/api/Wasatch.NET/class_wasatch_n_e_t_1_1_funky_float.html
|
|
546
|
+
def gain_to_ff(self, gain):
|
|
547
|
+
msb = int(round(gain, 5)) & 0xff
|
|
548
|
+
lsb = int((gain - msb) * 256) & 0xff
|
|
549
|
+
raw = (msb << 8) | lsb
|
|
550
|
+
log.debug(f"gain_to_ff: {gain:0.3f} -> dec {raw} (0x{raw:04x})")
|
|
551
|
+
return raw
|
|
552
|
+
|
|
553
|
+
def read_page(self, page: int): # -> list[bytes]
|
|
554
|
+
with self.lock:
|
|
555
|
+
# send 0xb0 command to tell FPGA to load EEPROM page into FPGA buffer
|
|
556
|
+
unbuffered_cmd = self.fix_crc([self.START, 0, 2, 0xb0, 0x40 + page, self.CRC, self.END])
|
|
557
|
+
buffered_response = bytearray(len(unbuffered_cmd) + self.READ_RESPONSE_OVERHEAD + 1)
|
|
558
|
+
buffered_cmd = self.buffer_bytearray(unbuffered_cmd, len(buffered_response))
|
|
559
|
+
self.SPI.write_readinto(buffered_cmd, buffered_response)
|
|
560
|
+
log.debug(f">> read_page: {self.to_hex(buffered_cmd)} -> {self.to_hex(buffered_response)}")
|
|
561
|
+
|
|
562
|
+
# MZ: API says "wait for SPEC_BUSY to be deasserted...why aren't we doing that?
|
|
563
|
+
time.sleep(0.01) # empirically determined 10ms delay
|
|
564
|
+
|
|
565
|
+
# send 0x31 command to read the buffered page from the FPGA
|
|
566
|
+
unbuffered_cmd = self.fix_crc([self.START, 0, 65, 0x31, self.CRC, self.END])
|
|
567
|
+
buffered_response = bytearray(len(unbuffered_cmd) + self.READ_RESPONSE_OVERHEAD + 64) # MZ: including kludged -1
|
|
568
|
+
buffered_cmd = self.buffer_bytearray(unbuffered_cmd, len(buffered_response))
|
|
569
|
+
self.SPI.write_readinto(buffered_cmd, buffered_response)
|
|
570
|
+
|
|
571
|
+
buf = self.decode_read_response(unbuffered_cmd, buffered_response, "read_eeprom_page", missing_echo_len=1)
|
|
572
|
+
log.debug(f"decoded {len(buf)} values from EEPROM")
|
|
573
|
+
return buf
|
|
574
|
+
|
|
575
|
+
def get_spectrum(self): # -> list[int]
|
|
576
|
+
with self.lock:
|
|
577
|
+
|
|
578
|
+
####################################################################
|
|
579
|
+
# Trigger Acquisition
|
|
580
|
+
####################################################################
|
|
581
|
+
|
|
582
|
+
if self.settings.state.trigger_source == SpectrometerState.TRIGGER_SOURCE_EXTERNAL:
|
|
583
|
+
log.debug("waiting on external trigger...")
|
|
584
|
+
self.wait_for_data_ready()
|
|
585
|
+
else:
|
|
586
|
+
# send trigger via the FT232H
|
|
587
|
+
self.trigger.value = True
|
|
588
|
+
self.wait_for_data_ready()
|
|
589
|
+
self.trigger.value = False
|
|
590
|
+
|
|
591
|
+
####################################################################
|
|
592
|
+
# Read the spectrum (MZ: big-endian, seriously?)
|
|
593
|
+
####################################################################
|
|
594
|
+
|
|
595
|
+
spectrum = []
|
|
596
|
+
pixels = self.settings.pixels()
|
|
597
|
+
bytes_remaining = pixels * 2
|
|
598
|
+
|
|
599
|
+
log.debug(f"get_spectrum: reading spectrum of {pixels} pixels")
|
|
600
|
+
raw = []
|
|
601
|
+
while self.ready.value:
|
|
602
|
+
if bytes_remaining > 0:
|
|
603
|
+
bytes_this_read = min(self.block_size, bytes_remaining)
|
|
604
|
+
|
|
605
|
+
# log.debug(f"get_spectrum: reading block of {bytes_this_read} bytes")
|
|
606
|
+
buf = bytearray(bytes_this_read)
|
|
607
|
+
|
|
608
|
+
# There is latency associated with this call, so call it as
|
|
609
|
+
# few times as possible (with the largest possible block
|
|
610
|
+
# size). Basically, I'm assuming each call requires a full
|
|
611
|
+
# 64-byte USB control packet to the FT232H (and response).
|
|
612
|
+
self.SPI.readinto(buf)
|
|
613
|
+
|
|
614
|
+
# log.debug(f"get_spectrum: read block of {len(buf)} bytes")
|
|
615
|
+
raw.extend(list(buf))
|
|
616
|
+
|
|
617
|
+
bytes_remaining -= len(buf)
|
|
618
|
+
|
|
619
|
+
########################################################################
|
|
620
|
+
# post-process spectrum
|
|
621
|
+
########################################################################
|
|
622
|
+
|
|
623
|
+
# demarshall big-endian
|
|
624
|
+
for i in range(0, len(raw)-1, 2):
|
|
625
|
+
spectrum.append((raw[i] << 8) | raw[i+1])
|
|
626
|
+
log.debug(f"get_spectrum: {len(spectrum)} pixels read ({spectrum[:3]} .. {spectrum[-3:]})")
|
|
627
|
+
|
|
628
|
+
return spectrum
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class SpectrometerRequest:
|
|
6
|
+
cmd: str = ''
|
|
7
|
+
args: list[Any] = field(default_factory=list)
|
|
8
|
+
kwargs: dict[Any, Any] = field(default_factory=dict)
|
|
9
|
+
|
|
10
|
+
def __str__(self):
|
|
11
|
+
return f"<SpectrometerResponse cmd {self.cmd}, args {self.args}, kwargs {self.kwargs}>"
|
|
12
|
+
|
|
13
|
+
def clear(self):
|
|
14
|
+
self.cmd = ''
|
|
15
|
+
self.args = []
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
class ErrorLevel(Enum):
|
|
6
|
+
ok = 0
|
|
7
|
+
low = 1
|
|
8
|
+
medium = 2
|
|
9
|
+
high = 3
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SpectrometerResponse:
|
|
13
|
+
data: Any = None
|
|
14
|
+
error_msg: str = ''
|
|
15
|
+
error_lvl: int = ErrorLevel.ok
|
|
16
|
+
poison_pill: bool = False
|
|
17
|
+
keep_alive: bool = False
|
|
18
|
+
incomplete: bool = False
|
|
19
|
+
progress: int = 0
|
|
20
|
+
|
|
21
|
+
def transfer_response(self, old_response):
|
|
22
|
+
self.data = old_response.data
|
|
23
|
+
self.error_msg = old_response.error_msg
|
|
24
|
+
self.error_lvl = old_response.error_lvl
|
|
25
|
+
self.poison_pill = old_response.poison_pill
|
|
26
|
+
self.keep_alive = old_response.keep_alive
|
|
27
|
+
self.incomplete = old_response.incomplete
|
|
28
|
+
self.progress = old_response.progress
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f"<SpectrometerResponse ({id(self)}), err_lvl {self.error_lvl.value}, Keep Alive {self.keep_alive}, Poison Pill {self.poison_pill}, err_msg {self.error_msg}, incomplete {self.incomplete}, progress {self.progress}>"
|
|
32
|
+
|
|
33
|
+
def clear(self):
|
|
34
|
+
self.data = None
|
|
35
|
+
self.error_msg = ''
|
|
36
|
+
self.error_lvl = ErrorLevel.low
|
|
37
|
+
self.poison_pill = False
|
|
38
|
+
self.keep_alive = False
|