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/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
|
+
|