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/BLEDevice.py ADDED
@@ -0,0 +1,1032 @@
1
+ import os
2
+ import asyncio
3
+ import logging
4
+
5
+ from bleak import BleakClient
6
+ from datetime import datetime
7
+ from threading import Thread
8
+ from functools import partial
9
+
10
+ from wasatch.EEPROM import EEPROM
11
+ from wasatch.Reading import Reading
12
+ from wasatch.ControlObject import ControlObject
13
+ from wasatch.InterfaceDevice import InterfaceDevice
14
+ from wasatch.SpectrometerRequest import SpectrometerRequest
15
+ from wasatch.SpectrometerSettings import SpectrometerSettings
16
+ from wasatch.SpectrometerResponse import SpectrometerResponse, ErrorLevel
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ # static, so available class-wide
21
+ def to_hex(a):
22
+ if a is None:
23
+ return "[ ]"
24
+ return "[ " + ", ".join([f"0x{v:02x}" for v in a]) + " ]"
25
+
26
+ class BLEDevice(InterfaceDevice):
27
+ """
28
+ This class is the BLE counterpart to our USB WasatchDevice, likewise
29
+ servicing requests from WrapperWorker in a dedicated spectrometer Thread.
30
+
31
+ WrapperWorker <--> InterfaceDevice
32
+ <>+-- WasatchDevice <-> pyusb (X, XM, XS USB spectrometers)
33
+ `-- BLEDevice <-----> bleak (XS BLE spectrometers)
34
+ `-- TCPDevice <-----> socket (network spectrometers)
35
+ `-- SPIDevice <-----> pyftdi (industrial spectrometers)
36
+ `-- OceanDevice <---> pyseabreeze (3rd-party spectrometers)
37
+
38
+ # Architecture
39
+
40
+ BLEManager instantiates a DeviceFinderBLE to generate a list of available
41
+ spectrometers.
42
+
43
+ DeviceFinderBLE instantiates a BleakScanner, and passes DiscoveredBLEDevices
44
+ (rssi and DeviceID (containing serial_number and bleak.BLEDevice)) back to
45
+ BLEManager so the user can pick one.
46
+
47
+ BLEManager adds the selected wasatch.DeviceID to Controller.other_device_ids,
48
+ which will presently pass it to a new WasatchDeviceWrapper and associated
49
+ WrapperWorker thread.
50
+
51
+ The WrapperWorker will then instantiate a wasatch.BLEDevice with the
52
+ wasatch.DeviceID. The wasatch.BLEDevice will use the DeviceID's embedded
53
+ bleak.BLEDevice to instantiate a BleakClient, which will be used for
54
+ subsequent communications.
55
+
56
+ # Concurrency
57
+
58
+ Legacy ENLIGHTEN WrapperWorker.run <--> wasatch.InterfaceDevice communication
59
+ normally occurs within a threading.Thread, using what we would now call
60
+ "traditional" (synchronous) Python function calls. That works fine for USB
61
+ and even TCP/IP devices, as both pyusb.ctrl_transfer and socket.recv/send
62
+ expose typical "blocking" procedural APIs. (Python didn't even get an async
63
+ keyword until 2015.)
64
+
65
+ However, BLE is all about asynchronous communication, and the Bleak package
66
+ only offers an async API. Therefore we need to wrap calls to the Bleak
67
+ objects using asyncio, so we can call async functions from synchronous code.
68
+
69
+ With regard to BLEManager <--> DeviceFinderBLE, BLEManager creates the
70
+ asyncio scan_loop, allowing DeviceFinderBLE to expose an asynchronous API
71
+ (public async methods).
72
+
73
+ That begs the question of how we should handle WrapperWorker <-->
74
+ wasatch.BLEDevice communication. Should wasatch.BLEDevice likewise expose
75
+ an asynchronous API, similar to what DeviceFinderBLE does, and let
76
+ WrapperWorker manage the asyncio run-loop?
77
+
78
+ After reflection, I think not. Right now we have a reasonably robust
79
+ WrapperWorker which is successfully talking to WP USB (WasatchDevice),
80
+ Andor USB (AndorDevice), TCP/IP (TCPDevice), and historically even SPI
81
+ (SPIDevice) and 3rd-party (OceanDevice) products. I don't want to "screw"
82
+ with WrapperWorker to make it friendly for BLE, and maybe break something
83
+ else.
84
+
85
+ So WrapperWorker is going to stay "synchronous", and we will let
86
+ wasatch.BLEDevice manage its own asyncio.run_loop for turning WrapperWorker
87
+ requests into await-able async calls.
88
+
89
+ I am unclear whether it is safe / recommended to have multiple asyncio
90
+ run_loops in a single application (especially when that application is built
91
+ atop PySide6, which has run-loops of its own). I don't know if this helps or
92
+ hurts, but currently I'm making the BLEDevice run-loop a public singleton so
93
+ it can be shared across BLEDevice instances, as well as with BLEManager.
94
+
95
+ # wasatch.DeviceID
96
+
97
+ The bleak.BLEDevice is attached to the wasatch.DeviceID because we have
98
+ existing infrastructure in enlighten.Controller to maintain a set of
99
+ .other_device_ids (DeviceID objects) it can check to connect to "non-self-
100
+ discovery" spectrometers. Currently those include WISP TCP/IP spectrometers
101
+ and BLE spectrometers, both of which require some level of user-interaction
102
+ to detect/find.
103
+
104
+ Controller then passes the wasatch.DeviceID into wasatch.WasatchDeviceWrapper
105
+ (and wasatch.WrapperWorker) to properly conenct to the requested device. WISP
106
+ needs IP addr:port, so those go into DeviceID, and BLE needs bleak.BLEDevice,
107
+ hence the extra attributes in DeviceID.
108
+
109
+ # BLE Discovery
110
+
111
+ In wasatch.DeviceFinderBLE, bleak.BleakScanner issues a series of
112
+ notifications including (bleak.backends.device.BLEDevice,
113
+ bleak.backends.scanner.AdvertisementData).
114
+
115
+ AdvertisementData contains these useful attributes:
116
+ - local_name <-- WP-12345
117
+ - rssi <--
118
+ - manufacturer_data
119
+ - platform_data
120
+ - service_data
121
+ - service_uuids
122
+ - tx_power
123
+
124
+ DeviceFinderBLE generates a wasatch.DeviceID with both displayable
125
+ (serial_number and rssi) and hidden (bleak_ble_device) attributes.
126
+
127
+ To generate a bleak.BleakClient, we need the bleak.backends.device.BLEDevice
128
+ from BleakScanner. Therefore, that handle needs to be be available in
129
+ wasatch.BLEDevice. The easiest way to get it there is for
130
+ wasatch.DeviceFinderBLE to embed it in the wasatch.DeviceID it writes to the
131
+ wasatch.DiscoveredBLEDevice that it sends back to enlighten.BLEManager, so
132
+ BLEManager can write the selected DeviceID to Controller.other_device_ids.
133
+
134
+ # Issues
135
+
136
+ Bleak Notifications don't seem to work on Windows under Parallels (using a
137
+ MacOS host). They work fine from a "real" Dell laptop, so this is probably
138
+ a Parallels bug.
139
+
140
+ # Misc
141
+
142
+ @todo consider https://github.com/django/asgiref#function-wrappers
143
+ """
144
+
145
+ WASATCH_SERVICE = "D1A7FF00-AF78-4449-A34F-4DA1AFAF51BC"
146
+ #DISCOVERY_SERVICE = "0000ff00-0000-1000-8000-00805f9b34fb"
147
+
148
+ CONNECT_TIMEOUT_SEC = 10
149
+
150
+ LASER_TEC_MODES = ['OFF', 'ON', 'AUTO', 'AUTO_ON']
151
+
152
+ ACQUIRE_STATUS_CODES = {
153
+ 0: ("NAK", "No error, the spectrum just isn't ready yet"),
154
+ 1: ("ERR_BATT_SOC_INFO_NOT_RCVD", "Can't read battery, and therefore can't take Auto-Dark or Auto-Raman spectra"),
155
+ 2: ("ERR_BATT_SOC_TOO_LOW", "Battery is too low to take Auto-Dark or Auto-Raman spectra"),
156
+ 3: ("ERR_LASER_DIS_FLR", "Failure disabling the laser"),
157
+ 4: ("ERR_LASER_ENA_FLR", "Failure enabling the laser"),
158
+ 5: ("ERR_IMG_SNSR_IN_BAD_STATE", "The sensor is not able to take spectra"),
159
+ 6: ("ERR_IMG_SNSR_STATE_TRANS_FLR","The sensor failed to apply acquisition parameters"),
160
+ 7: ("ERR_SPEC_ACQ_SIG_WAIT_TMO", "The sensor failed to take a spectrum (timeout exceeded)"),
161
+ 32: ("AUTO_OPT_TARGET_RATIO", "Auto-Raman is in the process of optimizing acquisition parameters"),
162
+ 33: ("TAKING_DARK", "taking spectra (no laser)"),
163
+ 34: ("LASER_WARNING_DELAY", "paused during laser warning delay period"),
164
+ 35: ("LASER_WARMUP", "paused during laser warmup period"),
165
+ 36: ("TAKING_RAMAN", "taking spectra (laser enabled)"),
166
+ }
167
+
168
+ # public singleton
169
+ static_run_loop = None
170
+ static_thread = None
171
+
172
+ @staticmethod
173
+ def get_run_loop():
174
+
175
+ def make_run_loop():
176
+ log.debug("make_run_loop: setting run_loop")
177
+ asyncio.set_event_loop(BLEDevice.static_run_loop)
178
+ log.debug("make_run_loop: running run_loop forever")
179
+ BLEDevice.static_run_loop.run_forever()
180
+ log.debug("make_run_loop: done")
181
+
182
+ if BLEDevice.static_run_loop is None:
183
+ log.debug("get_run_loop: instantiating event loop")
184
+ BLEDevice.static_run_loop = asyncio.new_event_loop()
185
+
186
+ log.debug("get_run_loop: instantiating Thread")
187
+ static_thread = Thread(target=make_run_loop, daemon=True)
188
+
189
+ log.debug("get_run_loop: starting thread")
190
+ static_thread.start()
191
+
192
+ return BLEDevice.static_run_loop
193
+
194
+ def __init__(self, device_id, message_queue=None, alert_queue=None):
195
+ log.debug("init: start")
196
+
197
+ super().__init__()
198
+ self.device_id = device_id
199
+
200
+ self.notifications = set() # all Characteristics to which we're subscribed for notifications
201
+
202
+ # Characteristics
203
+ self.code_by_name = { "LASER_STATE": 0xff03,
204
+ "ACQUIRE": 0xff04,
205
+ "BATTERY_STATE": 0xff09,
206
+ "GENERIC": 0xff0a }
207
+ self.name_by_uuid = { self.wrap_uuid(code): name for name, code in self.code_by_name.items() }
208
+
209
+ # Generics Name Lvl Set Get Size
210
+ self.generics = Generics()
211
+ self.generics.add("LASER_TEC_MODE", 0, 0x84, 0x85, 1)
212
+ self.generics.add("GAIN_DB", 0, 0xb7, 0xc5, 2, epsilon=0.01)
213
+ self.generics.add("INTEGRATION_TIME_MS", 0, 0xb2, 0xbf, 3)
214
+ self.generics.add("LASER_WARNING_DELAY_SEC", 0, 0x8a, 0x8b, 1)
215
+ self.generics.add("EEPROM_DATA", 1, None, 0x01, 1)
216
+ self.generics.add("START_LINE", 1, 0x21, 0x22, 2)
217
+ self.generics.add("STOP_LINE", 1, 0x23, 0x24, 2)
218
+ self.generics.add("AMBIENT_TEMPERATURE_DEG_C", 1, None, 0x2a, 1)
219
+ self.generics.add("POWER_WATCHDOG_SEC", 1, 0x30, 0x31, 2)
220
+ self.generics.add("SCANS_TO_AVERAGE", 1, 0x62, 0x63, 2)
221
+
222
+ ########################################################################
223
+ # things not in ble-util.py
224
+ ########################################################################
225
+
226
+ self.is_ble = True
227
+ self.is_andor = lambda : False
228
+
229
+ self.settings = SpectrometerSettings(self.device_id)
230
+
231
+ log.debug("init: creating scan_loop")
232
+ self.run_loop = self.get_run_loop()
233
+
234
+ log.debug("init: initializing process funcs")
235
+ self.process_f = self.init_process_funcs()
236
+
237
+ self.session_reading_count = 0
238
+
239
+ log.debug("init: done")
240
+
241
+ def __str__(self): return f"<BLEDevice device_id {self.device_id}>"
242
+ def __hash__(self): return hash(str(self))
243
+ def __repr__(self): return str(self)
244
+ def __eq__ (self, rhs): return hash(self) == hash(rhs)
245
+ def __ne__ (self, rhs): return str (self) != str(rhs)
246
+ def __lt__ (self, rhs): return str (self) < str(rhs)
247
+
248
+ def disconnect(self):
249
+ log.debug("disconnect: start")
250
+ try:
251
+ future = asyncio.run_coroutine_threadsafe(self.client.disconnect(), self.run_loop)
252
+ response = future.result()
253
+ except:
254
+ log.error("exception calling BleakClient.disconnect", exc_info=1)
255
+ log.debug("disconnect: done")
256
+ return SpectrometerResponse(True)
257
+
258
+ ###############################################################
259
+ # Public Methods
260
+ ###############################################################
261
+
262
+ def connect(self):
263
+ """
264
+ This is a synchronous method that WrapperWorker can call.
265
+ """
266
+ log.debug("connect: calling connect_async")
267
+ future = asyncio.run_coroutine_threadsafe(self.connect_async(), self.run_loop)
268
+ log.debug(f"connect: connect_async future pending ({future})")
269
+ response = future.result()
270
+ log.debug(f"connect: back from connect_async (response {response})")
271
+
272
+ return response
273
+
274
+ async def connect_async(self):
275
+ log.debug("connect_async: start")
276
+ start_time = datetime.now()
277
+
278
+ bleak_ble_device = self.device_id.bleak_ble_device
279
+ if bleak_ble_device is None:
280
+ msg = f"can't connect without bleak_ble_device"
281
+ log.critical(f"connect: {msg}")
282
+ return SpectrometerResponse(False, error_msg=msg)
283
+
284
+ log.debug("connect_async: instantiating BleakClient")
285
+ self.client = BleakClient(address_or_ble_device=bleak_ble_device,
286
+ disconnected_callback=self.disconnected_callback,
287
+ timeout=self.CONNECT_TIMEOUT_SEC)
288
+ log.debug(f"connect_async: BleakClient instantiated: {self.client}")
289
+
290
+ # no longer need handle in DeviceID, so clear it to simplify deepcopy etc
291
+ self.device_id.bleak_ble_device = None
292
+
293
+ log.debug(f"connect_async: calling client.connect")
294
+ await self.client.connect()
295
+
296
+ log.debug(f"connect_async: grabbing device information")
297
+ await self.load_device_information()
298
+
299
+ log.debug(f"connect_async: loading Characteristics")
300
+ await self.load_characteristics()
301
+
302
+ log.debug(f"connect_async: reading EEPROM")
303
+ await self.read_eeprom()
304
+
305
+ log.debug("connect_async: processing retrieved EEPROM")
306
+ self.settings.update_wavecal()
307
+
308
+ # grab initial integration time (used for acquisition timeout)
309
+ self.integration_time_ms = self.settings.eeprom.startup_integration_time_ms
310
+
311
+ msg = f"Connected to {self.settings.eeprom.model} {self.settings.eeprom.serial_number} with {self.settings.pixels()} pixels "
312
+ msg += f"from ({self.settings.wavelengths[0]:.2f}, {self.settings.wavelengths[-1]:.2f}nm)"
313
+ if self.settings.wavenumbers:
314
+ msg += f" ({self.settings.wavenumbers[0]:.2f}, {self.settings.wavenumbers[-1]:.2f}cm⁻¹)"
315
+ log.debug(msg)
316
+
317
+ elapsed_sec = (datetime.now() - start_time).total_seconds()
318
+ log.debug(f"connect_async: connection took {elapsed_sec:.2f} sec")
319
+
320
+ log.debug("connect_async: done")
321
+ return SpectrometerResponse(True)
322
+
323
+ ############################################################################
324
+ #
325
+ # from ble-util.py
326
+ #
327
+ ############################################################################
328
+
329
+ def disconnected_callback(self):
330
+ log.critical("disconnected")
331
+ # send poison-pill upstream?
332
+
333
+ async def load_device_information(self):
334
+ log.debug("Device Information:")
335
+ log.debug(f" address {self.client.address}")
336
+ log.debug(f" mtu_size {self.client.mtu_size} bytes")
337
+
338
+ device_info = {}
339
+ for service in self.client.services:
340
+ if "Device Information" in str(service):
341
+ for char in service.characteristics:
342
+ name = char.description
343
+ value = self.decode(await self.client.read_gatt_char(char.uuid))
344
+ device_info[name] = value
345
+ log.debug(f" {name} {value}")
346
+
347
+ return device_info
348
+
349
+ async def load_characteristics(self):
350
+ # find the primary service
351
+ primary_service = None
352
+ for service in self.client.services:
353
+ if service.uuid.lower() == self.WASATCH_SERVICE.lower():
354
+ primary_service = service
355
+
356
+ if primary_service is None:
357
+ return
358
+
359
+ # iterate over standard Characteristics
360
+ # @see https://bleak.readthedocs.io/en/latest/api/client.html#gatt-characteristics
361
+ log.debug("Characteristics:")
362
+ for char in primary_service.characteristics:
363
+ name = self.get_name_by_uuid(char.uuid)
364
+ extra = ""
365
+
366
+ if "write-without-response" in char.properties:
367
+ extra += f", Max write w/o rsp size: {char.max_write_without_response_size}"
368
+
369
+ props = ",".join(char.properties)
370
+ log.debug(f" {name:30s} {char.uuid} ({props}){extra}")
371
+
372
+ if "notify" in char.properties or "indicate" in char.properties:
373
+ if name == "BATTERY_STATE":
374
+ log.debug(f"starting {name} notifications")
375
+ await self.client.start_notify(char.uuid, self.battery_notification)
376
+ self.notifications.add(char.uuid)
377
+ elif name == "LASER_STATE":
378
+ log.debug(f"starting {name} notifications")
379
+ await self.client.start_notify(char.uuid, self.laser_state_notification)
380
+ self.notifications.add(char.uuid)
381
+ elif name == "GENERIC":
382
+ log.debug(f"starting {name} notifications")
383
+ await self.client.start_notify(char.uuid, self.generics.notification_callback)
384
+ self.notifications.add(char.uuid)
385
+ elif name == "ACQUIRE":
386
+ log.debug(f"starting {name} notifications")
387
+ await self.client.start_notify(char.uuid, self.acquire_notification)
388
+ self.notifications.add(char.uuid)
389
+
390
+ async def stop_notifications(self):
391
+ for uuid in self.notifications:
392
+ await self.client.stop_notify(uuid)
393
+
394
+ def battery_notification(self, sender, data):
395
+ charging = data[0] != 0
396
+ perc = int(data[1])
397
+ log.debug(f"received BATTERY_STATE notification: level {perc}%, charging {charging} (data {data})")
398
+
399
+ def laser_state_notification(self, sender, data):
400
+ status = self.parse_laser_state(data)
401
+ log.debug(f"received LASER_STATE notification: sender {sender}, data {data}: {status}")
402
+
403
+ async def read_char(self, name, min_len=None, quiet=False):
404
+ uuid = self.get_uuid_by_name(name)
405
+ if uuid is None:
406
+ raise RuntimeError(f"invalid characteristic {name}")
407
+
408
+ response = await self.client.read_gatt_char(uuid)
409
+ if response is None:
410
+ # responses may be optional on a write, but they're required on read
411
+ raise RuntimeError("attempt to read {name} returned no data")
412
+
413
+ if not quiet:
414
+ log.debug(f"<< read_char({name}, min_len {min_len}): {response}")
415
+
416
+ if min_len is not None and len(response) < min_len:
417
+ raise RuntimeError(f"characteristic {name} returned insufficient data ({len(response)} < {min_len})")
418
+
419
+ buf = bytearray()
420
+ for byte in response:
421
+ buf.append(byte)
422
+ return buf
423
+
424
+ async def write_char(self, name, data, quiet=False, callback=None, ack_name=None):
425
+ name = name.upper()
426
+ uuid = self.get_uuid_by_name(name)
427
+ if uuid is None:
428
+ raise RuntimeError(f"invalid characteristic {name}")
429
+ extra = []
430
+
431
+ if name == "GENERIC":
432
+ # STEP FIVE: allocate a new sequence number, and associate it with the passed callback
433
+ if callback is None and ack_name is not None:
434
+ # we weren't given an explicit callback, but this GENERIC opcode
435
+ # generates an acknowledgement, so setup a lambda to catch it (so
436
+ # we can block on it before returning)
437
+ callback = partial(self.generics.process_acknowledgement, name=ack_name)
438
+ seq = self.generics.next_seq(callback)
439
+ prefixed = [ seq ]
440
+ for v in data:
441
+ prefixed.append(v)
442
+ data = prefixed
443
+ extra.append(self.expand_path(name, data))
444
+
445
+ if not quiet:
446
+ code = self.code_by_name.get(name)
447
+ log.debug(f">> write_char({name} 0x{code:02x}, {to_hex(data)}){', '.join(extra)}")
448
+
449
+ if isinstance(data, list):
450
+ data = bytearray(data)
451
+
452
+ # MZ: I'm not sure why all writes require a response, but empirical
453
+ # testing indicates we reliably get randomly scrambled EEPROM contents
454
+ # without this.
455
+
456
+ # STEP SEVEN: actually write the cmd (or for Generic reads, "read request") to the Peripheral
457
+ await self.client.write_gatt_char(uuid, data, response=True)
458
+
459
+ if ack_name is not None:
460
+ # block on the acknowledgement we created above
461
+ log.debug(f"write_char: waiting for {ack_name} ack")
462
+ await self.generics.wait(ack_name)
463
+
464
+ def expand_path(self, name, data):
465
+ if name != "GENERIC":
466
+ return [ f"0x{v:02x}" for v in data ]
467
+ path = [ f"SEQ 0x{data[0]:02x}" ]
468
+ header = True
469
+ for i in range(1, len(data)):
470
+ if header:
471
+ code = data[i]
472
+ name = self.generics.get_name(code)
473
+ path.append(name)
474
+ if code != 0xff:
475
+ header = False
476
+ else:
477
+ path.append(f"0x{data[i]:02x}")
478
+ return "[" + ", ".join(path) + "]"
479
+
480
+ ############################################################################
481
+ # Timeouts
482
+ ############################################################################
483
+
484
+ async def set_power_watchdog_sec(self, sec):
485
+ await self.write_char("GENERIC", self.generics.generate_write_request("POWER_WATCHDOG_SEC", sec))
486
+
487
+ async def set_laser_warning_delay_sec(self, sec):
488
+ await self.write_char("GENERIC", self.generics.generate_write_request("LASER_WARNING_DELAY_SEC", sec))
489
+
490
+ ############################################################################
491
+ # Laser Control
492
+ ############################################################################
493
+
494
+ async def set_laser_enable(self, flag):
495
+ """
496
+ @bug mode and type should be settable to 0xff (same with watchdog)
497
+ """
498
+ log.debug(f"setting laser enable {flag}")
499
+ self.laser_enable = flag
500
+ await self.sync_laser_state()
501
+
502
+ async def sync_laser_state(self):
503
+ # kludge for BLE FW <4.8.9
504
+ laser_warning_delay_ms = self.laser_warning_delay_sec * 1000
505
+
506
+ data = [ 0x00, # mode
507
+ 0x00, # type
508
+ 0x01 if self.laser_enable else 0x00,
509
+ 0x00, # laser watchdog (DISABLE)
510
+ (laser_warning_delay_ms >> 8) & 0xff,
511
+ (laser_warning_delay_ms ) & 0xff ]
512
+ # 0xff # status mask
513
+ await self.write_char("LASER_STATE", data)
514
+
515
+ def parse_laser_state(self, data):
516
+ result = { 'laser_enable': False, 'laser_watchdog_sec': 0, 'status_mask': 0, 'laser_firing': False, 'interlock_closed': False }
517
+ size = len(data)
518
+ #if size > 0: result['mode'] = data[0]
519
+ #if size > 1: result['type'] = data[1]
520
+ if size > 2: result['laser_enable'] = data[2] != 0
521
+ if size > 3: result['laser_watchdog_sec'] = data[3]
522
+ # ignore bytes 4-5 (reserved)
523
+ if size > 6:
524
+ result['status_mask'] = data[6]
525
+ result['interlock_closed'] = data[6] & 0x01 != 0
526
+ result['laser_firing'] = data[6] & 0x02 != 0
527
+ return result
528
+
529
+ async def set_laser_tec_mode(self, mode: str):
530
+ index = self.LASER_TEC_MODES.index(mode)
531
+ await self.write_char("GENERIC", self.generics.generate_write_request("LASER_TEC_MODE", index))
532
+
533
+ ############################################################################
534
+ # Acquisition Parameters
535
+ ############################################################################
536
+
537
+ async def set_integration_time_ms(self, ms):
538
+ name = "INTEGRATION_TIME_MS"
539
+ log.debug(f"setting {name} to {ms}ms")
540
+ try:
541
+ await self.write_char("GENERIC", self.generics.generate_write_request(name, ms), ack_name=name)
542
+
543
+ self.settings.state.prev_integration_time_ms = self.settings.state.integration_time_ms
544
+ self.settings.state.integration_time_ms = ms
545
+ except:
546
+ log.error(f"error setting {name}", exc_info=1)
547
+ return SpectrometerResponse(True)
548
+
549
+ async def set_gain_db(self, db):
550
+ name = "GAIN_DB"
551
+ log.debug(f"setting {name} to {db}dB")
552
+ try:
553
+ await self.write_char("GENERIC", self.generics.generate_write_request(name, db), ack_name=name)
554
+
555
+ self.settings.state.gain_db = db
556
+ except:
557
+ log.error(f"error setting {name}", exc_info=1)
558
+ return SpectrometerResponse(True)
559
+
560
+ async def set_scans_to_average(self, n):
561
+ name = "SCANS_TO_AVERAGE"
562
+ log.debug(f"setting {name} to {n}")
563
+ try:
564
+ await self.write_char("GENERIC", self.generics.generate_write_request(name, n), ack_name=name)
565
+ self.settings.state.scans_to_average = n
566
+ except:
567
+ log.error(f"error setting {name}", exc_info=1)
568
+ return SpectrometerResponse(True)
569
+
570
+ async def set_start_line(self, n):
571
+ name = "START_LINE"
572
+ log.debug(f"setting {name} to {n}")
573
+ try:
574
+ await self.write_char("GENERIC", self.generics.generate_write_request(name, n))
575
+ except:
576
+ log.error(f"error setting {name}", exc_info=1)
577
+ return SpectrometerResponse(True)
578
+
579
+ async def set_stop_line(self, n):
580
+ name = "STOP_LINE"
581
+ log.debug(f"setting {name} to {n}")
582
+ try:
583
+ await self.write_char("GENERIC", self.generics.generate_write_request(name, n))
584
+ except:
585
+ log.error(f"error setting {name}", exc_info=1)
586
+ return SpectrometerResponse(True)
587
+
588
+ async def set_vertical_roi(self, pair):
589
+ await self.set_start_line(pair[0])
590
+ await self.set_stop_line (pair[1])
591
+ return SpectrometerResponse(True)
592
+
593
+ ############################################################################
594
+ # Monitor
595
+ ############################################################################
596
+
597
+ async def get_battery_state(self):
598
+ """
599
+ These will be pushed automatically 1/min from Central, if-and-only-if
600
+ no acquisition is occuring at the time of the scheduled event. However,
601
+ at least some(?) clients (Bleak on MacOS) don't seem to receive the
602
+ notifications until the next explicit read/write of a Characteristic
603
+ (any Chararacteristic? seemingly observed with ACQUIRE_CMD).
604
+ """
605
+ buf = await self.read_char("BATTERY_STATE", 2)
606
+ log.debug(f"battery response: {buf}")
607
+ return { 'charging': buf[0] != 0,
608
+ 'perc': int(buf[1]) }
609
+
610
+ async def get_laser_state(self):
611
+ retval = {}
612
+ for k in [ 'mode', 'type', 'enable', 'watchdog_sec', 'mask', 'interlock_closed', 'laser_firing' ]:
613
+ retval[k] = 'UNKNOWN'
614
+
615
+ buf = await self.read_char("LASER_STATE", 7)
616
+ if len(buf) >= 4:
617
+ retval.update({
618
+ 'mode': buf[0],
619
+ 'type': buf[1],
620
+ 'enable': buf[2],
621
+ 'watchdog_sec': buf[3] })
622
+
623
+ if len(buf) >= 7:
624
+ retval.update({
625
+ 'mask': buf[6],
626
+ 'interlock_closed':buf[6] & 0x01,
627
+ 'laser_firing': buf[6] & 0x02 })
628
+
629
+ return retval
630
+
631
+ async def get_status(self):
632
+ battery_state = await self.get_battery_state()
633
+ battery_perc = f"{battery_state['perc']:3d}%"
634
+ battery_charging = 'charging' if battery_state['charging'] else 'discharging'
635
+
636
+ laser_state = await self.get_laser_state()
637
+ laser_firing = laser_state['laser_firing']
638
+ interlock_closed = 'closed (armed)' if laser_state['interlock_closed'] else 'open (safe)'
639
+
640
+ amb_temp = await self.get_generic_value("AMBIENT_TEMPERATURE_DEG_C")
641
+ return f"Battery {battery_perc} ({battery_charging}), Laser {laser_firing}, Interlock {interlock_closed}, Amb {amb_temp}°C"
642
+
643
+ ############################################################################
644
+ # Generics
645
+ ############################################################################
646
+
647
+ # these methods belong in BLEDevice, rather than Generics, because they
648
+ # use write_char
649
+
650
+ async def get_generic_value(self, name):
651
+ request = self.generics.generate_read_request(name)
652
+ log.debug(f"get_generic: querying {name} ({to_hex(request)})")
653
+
654
+ await self.write_char("GENERIC", request, callback=lambda data: self.generics.process_response(name, data))
655
+ await self.generics.wait(name)
656
+
657
+ value = self.generics.get_value(name)
658
+ log.debug(f"get_generic: received {value}")
659
+ return value
660
+
661
+ ############################################################################
662
+ # Spectra
663
+ ############################################################################
664
+
665
+ def acquire_data(self):
666
+ future = asyncio.run_coroutine_threadsafe(self.get_spectrum(), self.run_loop)
667
+ spectrum = future.result()
668
+
669
+ log.debug(f"acquire_data: received spectrum of length {len(spectrum)}: {spectrum[:10]}")
670
+ self.session_reading_count += 1
671
+
672
+ reading = Reading()
673
+ reading.spectrum = spectrum
674
+ reading.averaged = True
675
+ reading.session_count = self.session_reading_count
676
+ reading.timestamp_complete = datetime.now()
677
+ reading.device_id = self.device_id
678
+
679
+ return SpectrometerResponse(data=reading)
680
+
681
+ def parse_acquire_status(self, status, payload):
682
+ if status not in self.ACQUIRE_STATUS_CODES:
683
+ raise RuntimeError("ACQUIRE notification included unsupported status code 0x{status:02x}, payload {payload}")
684
+
685
+ short, long = self.ACQUIRE_STATUS_CODES[status]
686
+ msg = f"{short}: {long}"
687
+
688
+ # special handling for status codes including payload
689
+ if status == 32:
690
+ targetRatio = int(payload[0])
691
+ msg += f" (target ratio {targetRatio}%)"
692
+ elif status in [33, 34, 35, 36]:
693
+ currentStep = int(payload[0] << 8 | payload[1])
694
+ totalSteps = int(payload[2] << 8 | payload[3])
695
+ if totalSteps > 1:
696
+ msg += f" (step {currentStep+1}/{totalSteps})"
697
+
698
+ return msg
699
+
700
+ def acquire_notification(self, sender, data):
701
+ ok = True
702
+ if (len(data) < 3):
703
+ raise RuntimeError(f"received invalid ACQUIRE notification of {len(data)} bytes: {data}")
704
+
705
+ # first two bytes declare whether it's a status message or spectral data
706
+ first_pixel = int((data[0] << 8) | data[1]) # big-endian int16
707
+
708
+ if first_pixel == 0xffff:
709
+ status = data[2]
710
+ payload = data[3:]
711
+ msg = self.parse_acquire_status(status, payload)
712
+ log.debug(f"acquire_notification: {msg}")
713
+ return
714
+
715
+ ########################################################################
716
+ # apparently it's spectral data
717
+ ########################################################################
718
+
719
+ # validate first_pixel
720
+ if first_pixel != self.pixels_read:
721
+ # raise RuntimeError(f"received first_pixel {first_pixel} when pixels_read {self.pixels_read}")
722
+ log.error(f"received first_pixel {first_pixel} when pixels_read {self.pixels_read} (ignoring)")
723
+ return
724
+
725
+ spectral_data = data[2:]
726
+ pixels_in_packet = int(len(spectral_data) / 2)
727
+
728
+ for i in range(pixels_in_packet):
729
+ # pixel intensities are little-endian uint16
730
+ offset = i * 2
731
+ intensity = int((spectral_data[offset+1] << 8) | spectral_data[offset])
732
+
733
+ self.spectrum[self.pixels_read] = intensity
734
+ self.pixels_read += 1
735
+
736
+ if self.pixels_read == self.settings.pixels():
737
+ # log.debug("read complete spectrum")
738
+ if (i + 1 != pixels_in_packet):
739
+ raise RuntimeError(f"trailing pixels in packet")
740
+
741
+ async def get_spectrum(self, spectrum_type=0):
742
+ self.pixels_read = 0
743
+ self.spectrum = [0] * self.settings.pixels()
744
+
745
+ # send the ACQUIRE
746
+ log.debug(f"sending ACQUIRE with type {spectrum_type}")
747
+ await self.write_char("ACQUIRE", [spectrum_type])
748
+
749
+ # compute timeout
750
+ timeout_ms = 4 * max(self.settings.state.prev_integration_time_ms, self.settings.state.integration_time_ms) * self.settings.state.scans_to_average + 6000 # 4sec latency + 2sec buffer
751
+
752
+ # wait for spectral data to arrive
753
+ keep_waiting = False
754
+ start_time = datetime.now()
755
+ while self.pixels_read < self.settings.pixels():
756
+ if not keep_waiting and (datetime.now() - start_time).total_seconds() * 1000 > timeout_ms:
757
+ raise RuntimeError(f"failed to read spectrum within timeout {timeout_ms}ms")
758
+
759
+ # log.debug(f"still waiting for spectra ({self.pixels_read}/{self.settings.pixels()} read)")
760
+ await asyncio.sleep(0.2)
761
+
762
+ ########################################################################
763
+ # post-processing
764
+ ########################################################################
765
+
766
+ # note, this needs updated for 633XS
767
+ log.debug("applying 2x2 binning")
768
+ binned = []
769
+ for i in range(len(self.spectrum)-1):
770
+ binned.append((self.spectrum[i] + self.spectrum[i+1]) / 2.0)
771
+ binned.append(self.spectrum[-1])
772
+ self.spectrum = binned
773
+
774
+ # @todo add bad-pixel correction
775
+ # @todo add invert_detector
776
+ # @todo add many things...
777
+
778
+ return self.spectrum
779
+
780
+ ############################################################################
781
+ # EEPROM
782
+ ############################################################################
783
+
784
+ async def read_eeprom(self):
785
+ await self.read_eeprom_pages()
786
+ self.settings.eeprom.parse(self.pages)
787
+
788
+ async def read_eeprom_pages(self):
789
+ """ tweaked version of get_generic_value """
790
+ start_time = datetime.now()
791
+
792
+ self.eeprom = {}
793
+ self.pages = []
794
+
795
+ name = "EEPROM_DATA"
796
+ for page in range(8):
797
+ buf = bytearray()
798
+ for subpage in range(4):
799
+
800
+ request = self.generics.generate_read_request(name)
801
+ request.append(0) # page is big-endian uint16
802
+ request.append(page)
803
+ request.append(subpage)
804
+
805
+ log.debug(f"read_eeprom_pages: querying {name} ({to_hex(request)})")
806
+ await self.write_char("GENERIC", request, callback=lambda data: self.generics.process_response(name, data))
807
+
808
+ log.debug(f"read_eeprom_pages: waiting on {name}")
809
+ await self.generics.wait(name)
810
+
811
+ data = self.generics.get_value(name)
812
+ log.debug(f"read_eeprom_pages: received page {page}, subpage {subpage}: {data}")
813
+
814
+ for byte in data:
815
+ buf.append(byte)
816
+ self.pages.append(buf)
817
+
818
+ elapsed_sec = (datetime.now() - start_time).total_seconds()
819
+ print(f"reading eeprom took {elapsed_sec:.2f} sec")
820
+
821
+ def init_process_funcs(self): # -> dict[str, Callable[..., Any]]
822
+ f = {}
823
+
824
+ # synchronous functions without arguments
825
+ f["connect"] = self.connect
826
+ f["disconnect"] = self.disconnect
827
+ f["close"] = self.disconnect
828
+ f["acquire_data"] = self.acquire_data
829
+
830
+ # asynchronous functions with arguments
831
+ f["scans_to_average"] = lambda n: asyncio.run_coroutine_threadsafe(self.set_scans_to_average(n), self.run_loop)
832
+ f["integration_time_ms"]= lambda ms: asyncio.run_coroutine_threadsafe(self.set_integration_time_ms(ms), self.run_loop)
833
+ f["detector_gain"] = lambda dB: asyncio.run_coroutine_threadsafe(self.set_detector_gain(dB), self.run_loop)
834
+ f["laser_enable"] = lambda flag: asyncio.run_coroutine_threadsafe(self.set_laser_enable(flag), self.run_loop)
835
+ f["vertical_binning"] = lambda roi: asyncio.run_coroutine_threadsafe(self.set_vertical_roi(roi), self.run_loop)
836
+
837
+ return f
838
+
839
+ ############################################################################
840
+ # Utility
841
+ ############################################################################
842
+
843
+ def decode(self, data):
844
+ try:
845
+ if isinstance(data, bytearray):
846
+ return data.decode('utf-8')
847
+ except:
848
+ pass
849
+ return data
850
+
851
+ def wrap_uuid(self, code):
852
+ return f"d1a7{code:04x}-af78-4449-a34f-4da1afaf51bc".lower()
853
+
854
+ def get_name_by_uuid(self, uuid):
855
+ return self.name_by_uuid.get(uuid.lower(), None)
856
+
857
+ def get_uuid_by_name(self, name):
858
+ code = self.code_by_name.get(name.upper(), None)
859
+ if code is None:
860
+ return
861
+ return self.wrap_uuid(code)
862
+
863
+ class Generic:
864
+ """ encapsulates paired setter and getter accessors for a single attribute """
865
+
866
+ def __init__(self, name, tier, setter, getter, size, epsilon):
867
+ self.name = name
868
+ self.tier = tier # 0, 1 or 2
869
+ self.setter = setter
870
+ self.getter = getter
871
+ self.size = size
872
+ self.epsilon = epsilon
873
+
874
+ self.value = None
875
+ self.event = asyncio.Event()
876
+
877
+ def serialize(self, value):
878
+ data = []
879
+ if self.name == "GAIN_DB":
880
+ data.append(int(value) & 0xff)
881
+ data.append(int((value - int(value)) * 256) & 0xff)
882
+ else:
883
+ # assume big-endian uint[size]
884
+ for i in range(self.size):
885
+ data.append((value >> (8 * (self.size - (i+1)))) & 0xff)
886
+ return data
887
+
888
+ def deserialize(self, data):
889
+ # STEP SIXTEEN: deserialize the returned Generic response payload according to the attribute type
890
+ if self.name == "GAIN_DB":
891
+ return data[0] + data[1] / 256.0
892
+ elif self.name == "EEPROM_DATA":
893
+ return data
894
+ else:
895
+ # by default, treat as big-endian uint
896
+ value = 0
897
+ for byte in data:
898
+ value <<= 8
899
+ value |= byte
900
+ return value
901
+
902
+ def generate_write_request(self, value):
903
+ if self.setter is None:
904
+ raise RuntimeError(f"Generic {self.name} is read-only")
905
+ request = [ 0xff for _ in range(self.tier) ]
906
+ request.append(self.setter)
907
+ request.extend(self.serialize(value))
908
+ return request
909
+
910
+ def generate_read_request(self):
911
+ # STEP THREE: generate the "read request" payload for this attribute
912
+ if self.getter is None:
913
+ raise RuntimeError(f"Generic {self.name} is write-only")
914
+ request = [ 0xff for _ in range(self.tier) ]
915
+ request.append(self.getter)
916
+ return request
917
+
918
+ class Generics:
919
+ """ Facade to access all Generic attributes in the BLE interface """
920
+
921
+ RESPONSE_ERRORS = [ 'OK', 'NO_RESPONSE_FROM_HOST', 'FPGA_READ_FAILURE', 'INVALID_ATTRIBUTE', 'UNSUPPORTED_COMMAND' ]
922
+
923
+ def __init__(self):
924
+ self.seq = 0
925
+ self.generics = {}
926
+ self.callbacks = {}
927
+
928
+ def next_seq(self, callback=None):
929
+ self.seq = (self.seq + 1) % 256
930
+ if self.seq in self.callbacks:
931
+ raise RuntimeError("seq {self.seq} has unprocessed callback {self.callbacks[self.seq]}")
932
+ elif callback:
933
+ # STEP SIX: store the callback function in a table, keyed on the new sequence number
934
+ self.callbacks[self.seq] = callback
935
+ return self.seq
936
+
937
+ def get_callback(self, seq):
938
+ # STEP ELEVEN: remove the stored callback from the table, so it won't accidentally be re-used
939
+ if seq in self.callbacks:
940
+ return self.callbacks.pop(seq)
941
+
942
+ # probably an uncaught acknowledgement from a generic setter like SET_INTEGRATION_TIME_MS
943
+ log.debug(f"get_callback: seq {seq} not found in callbacks")
944
+
945
+ # @todo: we're getting these from SET_START_LINE / SET_STOP_LINE, where
946
+ # the response notification doesn't seem to include the request's SEQ
947
+ # number
948
+
949
+ def add(self, name, tier, setter, getter, size, epsilon=0):
950
+ self.generics[name] = Generic(name, tier, setter, getter, size, epsilon)
951
+
952
+ def generate_write_request(self, name, value):
953
+ return self.generics[name].generate_write_request(value)
954
+
955
+ def generate_read_request(self, name):
956
+ # STEP TWO: generate the "read request" payload for the named attribute
957
+ return self.generics[name].generate_read_request()
958
+
959
+ def get_value(self, name):
960
+ return self.generics[name].value
961
+
962
+ async def wait(self, name):
963
+ await self.generics[name].event.wait()
964
+ self.generics[name].event.clear()
965
+
966
+ async def process_acknowledgement(self, data, name):
967
+ log.debug(f"received acknowledgement for {name}")
968
+ generic = self.generics[name]
969
+ generic.event.set()
970
+
971
+ async def process_response(self, name, data):
972
+ # STEP THIRTEEN: this is the standard callback triggered after receiving
973
+ # a notification from the Generic Characteristic
974
+
975
+ # STEP FOURTEEN: lookup the specific Generic attribute (AMBIENT_TEMPERATURE_DEG_C, etc) associated with this transaction
976
+ generic = self.generics[name]
977
+
978
+ # STEP FIFTEEN: parse the response payload according to the attribute
979
+ generic.value = generic.deserialize(data)
980
+
981
+ # STEP SEVENTEEN: raise the asynchronous "event" flag to tell the
982
+ # await'ing requester that the response value is now available and stored
983
+ # in the Generic object
984
+ generic.event.set()
985
+
986
+ async def notification_callback(self, sender, data):
987
+ # STEP EIGHT: we have received a response notification from the Generic Characteristic
988
+
989
+ log.debug(f"received GENERIC notification from sender {sender}, data {to_hex(data)}")
990
+
991
+ # STEP NINE: extract the sequence number from the notification response
992
+ result = None
993
+ if len(data) < 3:
994
+ seq, err = data[0], data[1]
995
+ else:
996
+ seq, err, result = data[0], data[1], data[2:]
997
+
998
+ if err < len(self.RESPONSE_ERRORS):
999
+ response_error = self.RESPONSE_ERRORS[err]
1000
+ else:
1001
+ response_error = f"UNSUPPORTED RESPONSE_ERROR: 0x{err}"
1002
+
1003
+ if response_error != "OK":
1004
+ raise RuntimeError(f"GENERIC notification included error code {err} ({response_error}); data {to_hex(data)}")
1005
+
1006
+ # STEP TEN: lookup the stored callback for this sequence number
1007
+ #
1008
+ # pass the response data, minus the sequence and error-code header, to
1009
+ # the registered callback function for that sequence ID
1010
+ callback = self.get_callback(seq)
1011
+
1012
+ # STEP TWELVE: actually call the callback
1013
+ if callback:
1014
+ await callback(result)
1015
+
1016
+ def get_name(self, code):
1017
+ if code == 0xff:
1018
+ return "NEXT_TIER"
1019
+
1020
+ for name, generic in self.generics.items():
1021
+ if code == generic.setter:
1022
+ return f"SET_{name}"
1023
+ elif code == generic.getter:
1024
+ return f"GET_{name}"
1025
+ return "UNKNOWN"
1026
+
1027
+ def equals(self, name, expected):
1028
+ actual = self.generics[name].value
1029
+ epsilon = self.generics[name].epsilon
1030
+ delta = abs(actual - expected)
1031
+ return delta <= epsilon
1032
+