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.
Files changed (53) hide show
  1. wasatch/AbstractUSBDevice.py +29 -0
  2. wasatch/AndorDevice.py +910 -0
  3. wasatch/AreaScanImage.py +19 -0
  4. wasatch/AutoRaman.py +457 -0
  5. wasatch/AutoRamanRequest.py +96 -0
  6. wasatch/BLEDevice.py +1032 -0
  7. wasatch/BalanceAcquisition.py +136 -0
  8. wasatch/CSVLoader.py +188 -0
  9. wasatch/CommandSettings.py +95 -0
  10. wasatch/ControlObject.py +17 -0
  11. wasatch/DetectorROI.py +36 -0
  12. wasatch/DetectorRegions.py +158 -0
  13. wasatch/DeviceFinderBLE.py +107 -0
  14. wasatch/DeviceFinderTCP.py +20 -0
  15. wasatch/DeviceFinderUSB.py +236 -0
  16. wasatch/DeviceID.py +344 -0
  17. wasatch/EEPROM.py +1450 -0
  18. wasatch/EEPROM_multical.sav +48 -0
  19. wasatch/FPGAOptions.py +125 -0
  20. wasatch/FeatureIdentificationDevice.py +3961 -0
  21. wasatch/FirmwareRequirements.py +76 -0
  22. wasatch/HardwareInfo.py +36 -0
  23. wasatch/IDSCamera.py +916 -0
  24. wasatch/IDSDevice.py +329 -0
  25. wasatch/IMX385.py +179 -0
  26. wasatch/InterfaceDevice.py +40 -0
  27. wasatch/MockUSBDevice.py +353 -0
  28. wasatch/OceanDevice.py +215 -0
  29. wasatch/PollStatus.py +15 -0
  30. wasatch/ProcessedReading.py +352 -0
  31. wasatch/ROI.py +33 -0
  32. wasatch/Reading.py +80 -0
  33. wasatch/RealUSBDevice.py +64 -0
  34. wasatch/SPIDevice.py +628 -0
  35. wasatch/SpectrometerRequest.py +15 -0
  36. wasatch/SpectrometerResponse.py +38 -0
  37. wasatch/SpectrometerSettings.py +446 -0
  38. wasatch/SpectrometerState.py +255 -0
  39. wasatch/StatusMessage.py +14 -0
  40. wasatch/TCPDevice.py +409 -0
  41. wasatch/TakeOneRequest.py +43 -0
  42. wasatch/WasatchBus.py +87 -0
  43. wasatch/WasatchDevice.py +1060 -0
  44. wasatch/WasatchDeviceWrapper.py +452 -0
  45. wasatch/WrapperWorker.py +304 -0
  46. wasatch/__init__.py +4 -0
  47. wasatch/applog.py +177 -0
  48. wasatch/simulation_protocol.py +227 -0
  49. wasatch/utils.py +731 -0
  50. wasatch-2.3.2.dist-info/LICENSE +21 -0
  51. wasatch-2.3.2.dist-info/METADATA +347 -0
  52. wasatch-2.3.2.dist-info/RECORD +53 -0
  53. 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