slm_open_api_package 0.2.10__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.
- slm_api/CPB_example.py +74 -0
- slm_api/__init__.py +5 -0
- slm_api/enums/fast_log_intervals_enum.py +16 -0
- slm_api/enums/sequence_id_enum.py +352 -0
- slm_api/example_multi.py +78 -0
- slm_api/fast_log_example.py +172 -0
- slm_api/flac_example.py +164 -0
- slm_api/helpers/FigureHandler.py +65 -0
- slm_api/helpers/Leq.py +87 -0
- slm_api/helpers/__init__.py +5 -0
- slm_api/helpers/buffer.py +27 -0
- slm_api/helpers/data_handler.py +94 -0
- slm_api/helpers/fft.py +38 -0
- slm_api/helpers/flac_stream_2_samples.py +64 -0
- slm_api/helpers/https_requests.py +79 -0
- slm_api/helpers/measurment_handler.py +20 -0
- slm_api/helpers/sequence_handler.py +43 -0
- slm_api/helpers/stream_handler.py +105 -0
- slm_api/helpers/stream_handlers.py +434 -0
- slm_api/helpers/tmpFileStateMachine.py +93 -0
- slm_api/helpers/websocket_handler.py +12 -0
- slm_api/helpers/webxi_helper_functions.py +79 -0
- slm_api/helpers/webxi_root.pem +24 -0
- slm_api/mp3_example.py +165 -0
- slm_api/multi_device_flac.py +105 -0
- slm_api/multi_instrument_example.py +74 -0
- slm_api/screen_record_example.py +64 -0
- slm_api/webxi/README.md +25 -0
- slm_api/webxi/__init__.py +5 -0
- slm_api/webxi/webxi-header.ksy +32 -0
- slm_api/webxi/webxi-stream.ksy +342 -0
- slm_api/webxi/webxi_header.py +42 -0
- slm_api/webxi/webxi_stream.py +450 -0
- slm_open_api_package-0.2.10.dist-info/METADATA +206 -0
- slm_open_api_package-0.2.10.dist-info/RECORD +37 -0
- slm_open_api_package-0.2.10.dist-info/WHEEL +4 -0
- slm_open_api_package-0.2.10.dist-info/entry_points.txt +3 -0
slm_api/flac_example.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import requests
|
|
3
|
+
import sys
|
|
4
|
+
import pyqtgraph as pg
|
|
5
|
+
import numpy as np
|
|
6
|
+
from slm_api.helpers import webxi_helper_functions as webxi_helper
|
|
7
|
+
|
|
8
|
+
# Modules to convert webxi data
|
|
9
|
+
from slm_api.webxi import webxi_stream as webxiStream
|
|
10
|
+
# Help functions located in the helpers folder
|
|
11
|
+
# Read these files to get examples on how to communicate with the SLM
|
|
12
|
+
from slm_api.helpers import stream_handler as stream # SLM stream functions
|
|
13
|
+
# Start/pause/Stop measurments functions
|
|
14
|
+
from slm_api.helpers import measurment_handler as meas
|
|
15
|
+
# Get sequences,
|
|
16
|
+
from slm_api.helpers import sequence_handler as seq
|
|
17
|
+
# Stream handler
|
|
18
|
+
from slm_api.helpers import stream_handlers as sh
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Async functions to control communication
|
|
22
|
+
from slm_api.helpers import websocket_handler as webSocket
|
|
23
|
+
from timeit import default_timer as timer
|
|
24
|
+
# Buffer and decoder for the flac stream
|
|
25
|
+
from slm_api.helpers.buffer import DataBuffer
|
|
26
|
+
from slm_api.helpers.fft import dBfft
|
|
27
|
+
from slm_api.helpers import flac_stream_2_samples as flac2samples
|
|
28
|
+
import threading
|
|
29
|
+
|
|
30
|
+
from slm_api.helpers.FigureHandler import FigureHandler
|
|
31
|
+
from slm_api.helpers.data_handler import MultiDataHandler, CsvDataHandler
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# FLAC streaming is only available on 2255
|
|
35
|
+
|
|
36
|
+
host = webxi_helper.set_host(__file__)
|
|
37
|
+
|
|
38
|
+
sequenceID = 157
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# class streamHandler:
|
|
42
|
+
# def __init__(self, startStream=False):
|
|
43
|
+
# self.i = 0
|
|
44
|
+
# self.max_input = 15.6263 / np.sqrt(2)
|
|
45
|
+
# self.streamInit()
|
|
46
|
+
# if startStream:
|
|
47
|
+
# self.startStream()
|
|
48
|
+
|
|
49
|
+
# def decode_flac_stream(self, message):
|
|
50
|
+
# start = timer()
|
|
51
|
+
# package = webxiStream.WebxiStream.from_bytes(message)
|
|
52
|
+
# if package.header.message_type == webxiStream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
53
|
+
# # Get the encoded flac block
|
|
54
|
+
# flac = package.content.sequence_blocks[0]
|
|
55
|
+
# # Decode the compressed samples and add it to the data bufffer
|
|
56
|
+
# DataBuffer.append(flac2samples.decode(flac, self.calibrationFactor))
|
|
57
|
+
# end = timer()
|
|
58
|
+
# total = (end - start)
|
|
59
|
+
# if 0.0625 < total:
|
|
60
|
+
# print(f"TotalTime: {total}")
|
|
61
|
+
# if not self.StreamRun:
|
|
62
|
+
# self._resolve()
|
|
63
|
+
|
|
64
|
+
# def get_calibration_factor(self):
|
|
65
|
+
# # Calculate calibration factor from the microphone sensitivity
|
|
66
|
+
# response = requests.get(f"{host}/WebXi/Applications/SLM/Outputs/Sensitivity")
|
|
67
|
+
# assert (response.status_code == 200)
|
|
68
|
+
# mic_sens = float(response.text) # V/Pa
|
|
69
|
+
# max_lvl = 20 * np.log10((self.max_input / mic_sens) / 20e-6) # dB SPL re 20 uPa
|
|
70
|
+
# self.calibrationFactor = (20e-6 * 10 ** (max_lvl / 20)) / (2 ** 23 - 1) * np.sqrt(2)
|
|
71
|
+
|
|
72
|
+
# def streamInit(self):
|
|
73
|
+
# # Enable audio recording analysis quality
|
|
74
|
+
# response = requests.put(f"{host}/WebXi/Applications/SLM/setup/AudioRecordingAnalysisQuality", json = 1)
|
|
75
|
+
# assert(response.status_code == 200)
|
|
76
|
+
# self.get_calibration_factor()
|
|
77
|
+
# self.ID, self.sequence = seq.get_sequence(host, sequenceID)
|
|
78
|
+
# # Get URI for stream
|
|
79
|
+
# self.streamName = "Flac stream"
|
|
80
|
+
# self.uri = stream.setup_stream(host, self.ID, self.streamName)
|
|
81
|
+
|
|
82
|
+
# # Start a measurement. This is needed to obtain data from the device
|
|
83
|
+
# meas.start_pause_measurement(host, True)
|
|
84
|
+
|
|
85
|
+
# def startStream(self):
|
|
86
|
+
# self.StreamRun = True
|
|
87
|
+
|
|
88
|
+
# asyncio.run(self.runStream())
|
|
89
|
+
|
|
90
|
+
# async def runStream(self):
|
|
91
|
+
# self.loop = asyncio.get_running_loop()
|
|
92
|
+
# self.fut = self.loop.create_future()
|
|
93
|
+
# # Create lambda function to use for the stream message. In this example is a function
|
|
94
|
+
# # call used
|
|
95
|
+
# self.msg_func = lambda msg : self.decode_flac_stream(msg)
|
|
96
|
+
# # Initilize and run the websocket to retrive data
|
|
97
|
+
|
|
98
|
+
# task = self.loop.create_task(webSocket.next_async_websocket(self.uri, self.msg_func))
|
|
99
|
+
# await self.fut
|
|
100
|
+
# task.cancel()
|
|
101
|
+
# meas.stop_measurement(host)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# def stopStream(self):
|
|
105
|
+
# self.StreamRun = False
|
|
106
|
+
# # Resolve the future directly; waiting for the next message may never happen
|
|
107
|
+
# if hasattr(self, "loop"):
|
|
108
|
+
# self.loop.call_soon_threadsafe(self._resolve)
|
|
109
|
+
# stream.delete_stream(host, self.streamName) # Cleaning up and deleting the stream used
|
|
110
|
+
|
|
111
|
+
# def _resolve(self):
|
|
112
|
+
# if not self.fut.done():
|
|
113
|
+
# self.fut.set_result(True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class figureHandler(FigureHandler):
|
|
117
|
+
|
|
118
|
+
def axisConfig(self):
|
|
119
|
+
self.plotTime.getAxis('left').setStyle(tickFont=pg.QtGui.QFont('Arial', 11))
|
|
120
|
+
self.plotTime.getAxis('bottom').setStyle(tickFont=pg.QtGui.QFont('Arial', 11))
|
|
121
|
+
self.plotTime.getAxis('left').setLabel('Pressure', units='Pa', **self.labelStyle)
|
|
122
|
+
self.plotTime.getAxis('bottom').setLabel('Time', units='s', **self.labelStyle)
|
|
123
|
+
self.plotFreq.getAxis('left').setStyle(tickFont=pg.QtGui.QFont('Arial', 14))
|
|
124
|
+
self.plotFreq.getAxis('bottom').setStyle(tickFont=pg.QtGui.QFont('Arial', 14))
|
|
125
|
+
self.plotFreq.getAxis('left').setLabel('dB SPL re 20 µPa', **self.labelStyle)
|
|
126
|
+
self.plotFreq.getAxis('bottom').setLabel('Frequency', units='Hz', **self.labelStyle)
|
|
127
|
+
|
|
128
|
+
def update(self):
|
|
129
|
+
signal = DataBuffer.getPart(self.chunkToShow)
|
|
130
|
+
x = np.linspace(np.min(self.axis), np.max(self.axis), len(signal))
|
|
131
|
+
freq, s_dbfs = dBfft(signal, 2**16, self.fftHamming, ref=20e-6) #Reference = 20µPa
|
|
132
|
+
# Average the fft for a smoother plot
|
|
133
|
+
avg = s_dbfs / 3 + self.old / 3 + self.oldold / 3
|
|
134
|
+
self.curveTime.setData(x, signal)
|
|
135
|
+
self.curveFreq.setData(freq, avg)
|
|
136
|
+
self.oldold = self.old
|
|
137
|
+
self.old = s_dbfs
|
|
138
|
+
if (self.i % 5 == 0):
|
|
139
|
+
# Autoscale and print min/max values every 0.5 seconds
|
|
140
|
+
min_Pa = np.round(min(signal), 2)
|
|
141
|
+
max_Pa = np.round(max(signal), 2)
|
|
142
|
+
fft_peak = np.round(max(avg), 2)
|
|
143
|
+
fft_min = np.round(min(avg), 2)
|
|
144
|
+
peak_freq = (freq[np.argmax(avg)])
|
|
145
|
+
if (min_Pa != max_Pa):
|
|
146
|
+
self.plotTime.setYRange(min_Pa * 1.2, max_Pa * 1.2)
|
|
147
|
+
if (np.isinf(fft_peak) == False):
|
|
148
|
+
self.plotFreq.setYRange(fft_min, fft_peak * 1.2)
|
|
149
|
+
print(f"Min: {min_Pa} Pa, Max: {max_Pa} Pa, Peak: {fft_peak} dB SPL, Peak freq: {peak_freq} Hz")
|
|
150
|
+
self.i += 1
|
|
151
|
+
|
|
152
|
+
def on_close():
|
|
153
|
+
streamer.stopStream()
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
# streamer = streamHandler()
|
|
157
|
+
streamer = sh.WebXiStreamHandler(host, sequenceID=sequenceID, mode="flac", saving="csv", saving_path=r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt")
|
|
158
|
+
# streamer.setDataHandler(MultiDataHandler(PrintDataHandler(), CsvDataHandler(r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt")))
|
|
159
|
+
fig = figureHandler()
|
|
160
|
+
fig.app.aboutToQuit.connect(on_close)
|
|
161
|
+
threading.Thread(target=streamer.startStream, daemon=True).start()
|
|
162
|
+
fig.run()
|
|
163
|
+
|
|
164
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pyqtgraph as pg
|
|
2
|
+
import sys
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PyQt5 import QtWidgets
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
class FigureHandler(ABC):
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.i = 0
|
|
11
|
+
self.chunkToShow = 2 ** 15
|
|
12
|
+
self.fftSize = self.chunkToShow
|
|
13
|
+
self.fftHamming = np.hamming(self.fftSize)
|
|
14
|
+
# Used to store "old" spectrums for fft averaging
|
|
15
|
+
self.old = 0
|
|
16
|
+
self.oldold = 0
|
|
17
|
+
|
|
18
|
+
self.x = np.linspace(0.0, 10, self.chunkToShow)
|
|
19
|
+
|
|
20
|
+
## Figures
|
|
21
|
+
self.axis = np.arange(self.chunkToShow)
|
|
22
|
+
self.axis = np.flip(self.axis * -1 / self.chunkToShow)
|
|
23
|
+
self.x = np.zeros(len(self.axis))
|
|
24
|
+
self.app = QtWidgets.QApplication(sys.argv)
|
|
25
|
+
self.win = pg.GraphicsLayoutWidget(title="Streaming")
|
|
26
|
+
self.win.setBackground('w')
|
|
27
|
+
self.win.resize(1000, 600)
|
|
28
|
+
self.plotTime = self.win.addPlot()
|
|
29
|
+
self.curveTime = self.plotTime.plot(self.x, self.axis)
|
|
30
|
+
self.curveTime.setPen(color='b', width=2, autoDownsample=True, clipToView=True)
|
|
31
|
+
self.plotTime.showGrid(x=True, y=True)
|
|
32
|
+
self.plotTime.setXRange(np.min(self.axis), np.max(self.axis))
|
|
33
|
+
self.plotTime.setYRange(-2, 2)
|
|
34
|
+
self.labelStyle = {'color': '#000', 'font-size': '13pt'}
|
|
35
|
+
|
|
36
|
+
# Subplot 2 (FFT)
|
|
37
|
+
# Calculate the frequency vector
|
|
38
|
+
self.win.nextRow()
|
|
39
|
+
self.plotFreq = self.win.addPlot()
|
|
40
|
+
freq = np.arange(self.fftSize//2 + 1) / (float(self.fftSize) / 32e3)
|
|
41
|
+
self.curveFreq = self.plotFreq.plot(freq, np.arange(len(freq)))
|
|
42
|
+
self.curveFreq.setPen(color='b', width=2, autoDownsample=True, clipToView=True)
|
|
43
|
+
self.plotFreq.setXRange(0, np.max(freq))
|
|
44
|
+
self.plotFreq.setYRange(-20, 130)
|
|
45
|
+
self.plotFreq.getAxis('bottom').enableAutoSIPrefix(enable=False)
|
|
46
|
+
self.plotFreq.setLogMode(x=True, y=False)
|
|
47
|
+
self.plotFreq.showGrid(x=True, y=True)
|
|
48
|
+
self.axisConfig()
|
|
49
|
+
self.win.show()
|
|
50
|
+
|
|
51
|
+
self.timer = pg.QtCore.QTimer()
|
|
52
|
+
self.timer.timeout.connect(self.update)
|
|
53
|
+
self.timer.start(int(0.1 * 1000))
|
|
54
|
+
|
|
55
|
+
def run(self):
|
|
56
|
+
QtWidgets.QApplication.instance().exec_()
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def axisConfig(self):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def update(self):
|
|
64
|
+
pass
|
|
65
|
+
|
slm_api/helpers/Leq.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from . import https_requests
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def SLM_Setup_LAeq(host):
|
|
6
|
+
# Enable logging mode
|
|
7
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/ControlLoggingMode", json=1)
|
|
8
|
+
# Set the device in free runnign mode
|
|
9
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/ControlMeasurementTimeControl", json=0)
|
|
10
|
+
# Enable A weight frequency broad band weighting, but first clear the other once to be sure
|
|
11
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/BBFreqWeightB", json=False)
|
|
12
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/BBFreqWeightC", json=False)
|
|
13
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/BBFreqWeightZ", json=False)
|
|
14
|
+
https_requests.put(host, "/webxi/Applications/SLM/Setup/BBFreqWeightA", json=True)
|
|
15
|
+
# Enable LAeq mode on the device
|
|
16
|
+
https_requests.put(host, "/webxi/applications/slm/setup/BBLAeq", json=True)
|
|
17
|
+
|
|
18
|
+
class MovingLeq:
|
|
19
|
+
"""Class to create a moving Leq object.\n
|
|
20
|
+
Stores a window of Leq logging intervals to be combined into a total Leq\n
|
|
21
|
+
Calling move(new_interval) on the object will overwrite the oldest value in the
|
|
22
|
+
window, recalculate the total Leq and return it."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, window_length_sec, storedata=False, windowSize=None):
|
|
25
|
+
self.window_length = window_length_sec
|
|
26
|
+
windowSize = self.window_length * 10 + 1 if windowSize is None else windowSize
|
|
27
|
+
|
|
28
|
+
# Intialize an empty array, index to track oldest value, and window status
|
|
29
|
+
self.leq_window = np.zeros(window_length_sec)
|
|
30
|
+
self.leq_window.fill(np.nan)
|
|
31
|
+
self.storedata = storedata
|
|
32
|
+
if self.storedata:
|
|
33
|
+
self.rawData = LeqData(windowSize) # Used to store the raw data for plotting
|
|
34
|
+
self.leqData = LeqData(windowSize) # Used to store the Leq data for plotting
|
|
35
|
+
self.oldest_value_index = 0
|
|
36
|
+
self.leq_total = 0.0
|
|
37
|
+
self.window_full = False
|
|
38
|
+
|
|
39
|
+
def total_leq(self,window):
|
|
40
|
+
""" Function to combine several Leq periods into a total Leq, as per:\n
|
|
41
|
+
10 * log10(10^(L1/10) + 10^(L2/10) + ... 10^(Ln/10) / n)"""
|
|
42
|
+
sound_Pa = np.sum(np.power(10,window/10)) / len(window)
|
|
43
|
+
return 10 * np.log10(sound_Pa)
|
|
44
|
+
|
|
45
|
+
def move(self, new_value):
|
|
46
|
+
"""Overwrite the oldest value in the array with the new logging interval
|
|
47
|
+
and update the oldest value index to point to the next oldest value"""
|
|
48
|
+
self.leq_window[self.oldest_value_index] = new_value
|
|
49
|
+
self.oldest_value_index += 1
|
|
50
|
+
|
|
51
|
+
# Calculate the total Leq for the moving window and return it.
|
|
52
|
+
if self.window_full:
|
|
53
|
+
self.leq_total = self.total_leq(self.leq_window)
|
|
54
|
+
self.oldest_value_index = self.oldest_value_index % self.window_length
|
|
55
|
+
if self.storedata:
|
|
56
|
+
self.leqData.move(self.leq_total)
|
|
57
|
+
self.rawData.move(new_value)
|
|
58
|
+
return self.leq_total
|
|
59
|
+
|
|
60
|
+
# If the moving window is not full of measured data, simply calculate total Leq
|
|
61
|
+
# for the data that is in the window (and ignore the empty part of the array)
|
|
62
|
+
else:
|
|
63
|
+
self.leq_total = self.total_leq(self.leq_window[:self.oldest_value_index])
|
|
64
|
+
self.oldest_value_index = self.oldest_value_index % self.window_length
|
|
65
|
+
if self.oldest_value_index == self.window_length - 1:
|
|
66
|
+
self.window_full = True
|
|
67
|
+
if self.storedata:
|
|
68
|
+
self.leqData.move(self.leq_total)
|
|
69
|
+
self.rawData.move(new_value)
|
|
70
|
+
return self.leq_total
|
|
71
|
+
|
|
72
|
+
def getPlotData(self,Leq):
|
|
73
|
+
if Leq:
|
|
74
|
+
return self.leqData.getData()
|
|
75
|
+
else:
|
|
76
|
+
return self.rawData.getData()
|
|
77
|
+
|
|
78
|
+
class LeqData:
|
|
79
|
+
def __init__(self,window_length_sec):
|
|
80
|
+
self.leq_window = np.zeros(window_length_sec)
|
|
81
|
+
self.leq_window[:] = np.nan
|
|
82
|
+
|
|
83
|
+
def move(self, new_value):
|
|
84
|
+
self.leq_window = np.insert(self.leq_window[1:], self.leq_window.size-1,new_value)
|
|
85
|
+
|
|
86
|
+
def getData(self):
|
|
87
|
+
return self.leq_window
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
class buffer:
|
|
4
|
+
def __init__(self, size):
|
|
5
|
+
self.size = size
|
|
6
|
+
self.data = np.zeros(self.size)
|
|
7
|
+
|
|
8
|
+
def append(self, x):
|
|
9
|
+
"""
|
|
10
|
+
Adds data in the front of the buffer. Discard the oldest data if buffer is full
|
|
11
|
+
"""
|
|
12
|
+
self.data = np.append(self.data[-(self.size - len(x)) :: ] , x)
|
|
13
|
+
|
|
14
|
+
def get(self):
|
|
15
|
+
"""
|
|
16
|
+
Returns the whole buffer
|
|
17
|
+
"""
|
|
18
|
+
return self.data
|
|
19
|
+
|
|
20
|
+
def getPart(self, start = 2**16):
|
|
21
|
+
"""
|
|
22
|
+
Returns X points of the newest data.
|
|
23
|
+
"""
|
|
24
|
+
return self.data[-start :: ]
|
|
25
|
+
|
|
26
|
+
# Create databuffer to store the converted package data
|
|
27
|
+
DataBuffer = buffer(2**16)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Optional DataHandler for WebXiStreamHandler. By default WebXiStreamHandler
|
|
2
|
+
does nothing with decoded values - call streamer.setDataHandler(...) with an
|
|
3
|
+
instance of one of these (or your own subclass) before startStream() to
|
|
4
|
+
receive/print/save them."""
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import pickle
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DataHandler:
|
|
13
|
+
"""Override handle() to decide what happens with decoded stream values.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle(self, **data):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _BufferedFileDataHandler(DataHandler):
|
|
22
|
+
"""Base class for handlers that buffer records in memory and write them
|
|
23
|
+
all to disk in close(). Call streamer.setDataHandler(...) before startStream(),
|
|
24
|
+
then handler.close() (or MultiDataHandler.close()) when you stop the stream."""
|
|
25
|
+
|
|
26
|
+
_extension = None # set by subclasses, e.g. "json", "csv", "pkl"
|
|
27
|
+
|
|
28
|
+
def __init__(self, path):
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if self._extension and path.suffix.lower() != f".{self._extension}":
|
|
31
|
+
path = path.with_name(path.name + f".{self._extension}")
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self._path = path
|
|
34
|
+
self._records = []
|
|
35
|
+
|
|
36
|
+
def handle(self, **data):
|
|
37
|
+
if "values" in data:
|
|
38
|
+
data["values"] = [float(v) for v in data["values"]]
|
|
39
|
+
elif "samples" in data:
|
|
40
|
+
samples = data.pop("samples")
|
|
41
|
+
data.update(count=len(samples), min=float(min(samples)), max=float(max(samples)))
|
|
42
|
+
self._records.append(data)
|
|
43
|
+
|
|
44
|
+
def close(self):
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JsonDataHandler(_BufferedFileDataHandler):
|
|
49
|
+
"""Writes all buffered records to a single JSON file on close()."""
|
|
50
|
+
_extension = "json"
|
|
51
|
+
|
|
52
|
+
def close(self):
|
|
53
|
+
with open(self._path, "w") as f:
|
|
54
|
+
json.dump(self._records, f, indent=2, default=str)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CsvDataHandler(_BufferedFileDataHandler):
|
|
58
|
+
"""Writes all buffered records to a CSV file on close(). Columns vary per
|
|
59
|
+
row since different modes carry different fields."""
|
|
60
|
+
_extension = "csv"
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
fieldnames = list(dict.fromkeys(key for record in self._records for key in record))
|
|
64
|
+
with open(self._path, "w", newline="") as f:
|
|
65
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
66
|
+
writer.writeheader()
|
|
67
|
+
writer.writerows(self._records)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PickleDataHandler(_BufferedFileDataHandler):
|
|
71
|
+
"""Writes all buffered records (as Python objects) to a pickle file on close()."""
|
|
72
|
+
_extension = "pkl"
|
|
73
|
+
|
|
74
|
+
def close(self):
|
|
75
|
+
with open(self._path, "wb") as f:
|
|
76
|
+
pickle.dump(self._records, f)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MultiDataHandler(DataHandler):
|
|
82
|
+
"""Fans out to several handlers at once (e.g. print AND save AND plot)."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, *handlers):
|
|
85
|
+
self._handlers = handlers
|
|
86
|
+
|
|
87
|
+
def handle(self, **data):
|
|
88
|
+
for h in self._handlers:
|
|
89
|
+
h.handle(**data)
|
|
90
|
+
|
|
91
|
+
def close(self):
|
|
92
|
+
for h in self._handlers:
|
|
93
|
+
if hasattr(h, "close"):
|
|
94
|
+
h.close()
|
slm_api/helpers/fft.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
def dBfft(x, fs, win=None, ref=32768):
|
|
4
|
+
"""
|
|
5
|
+
Calculate spectrum in dB scale
|
|
6
|
+
Args:
|
|
7
|
+
x: input signal
|
|
8
|
+
fs: sampling frequency
|
|
9
|
+
win: vector containing window samples (same length as x).
|
|
10
|
+
If not provided, then rectangular window is used by default.
|
|
11
|
+
ref: reference value used for dBFS scale. 32768 for int16 and 1 for float
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
freq: frequency vector
|
|
15
|
+
s_db: spectrum in dB scale
|
|
16
|
+
"""
|
|
17
|
+
N = len(x) # Length of input sequence
|
|
18
|
+
|
|
19
|
+
if win is None:
|
|
20
|
+
win = np.ones(1, N)
|
|
21
|
+
if len(x) != len(win):
|
|
22
|
+
raise ValueError('Signal and window must be of the same length')
|
|
23
|
+
x = x * win
|
|
24
|
+
|
|
25
|
+
# Calculate real FFT and frequency vector
|
|
26
|
+
sp = np.fft.rfft(x)
|
|
27
|
+
freq = np.arange((N / 2) + 1) / (float(N) / fs)
|
|
28
|
+
|
|
29
|
+
# Scale the magnitude of FFT by window and factor of 2,
|
|
30
|
+
# because we are using half of FFT spectrum.
|
|
31
|
+
s_mag = (np.abs(sp) * np.sqrt(2)) / np.sum(win)
|
|
32
|
+
|
|
33
|
+
for i in range(len(s_mag)):
|
|
34
|
+
if s_mag[i] == 0:
|
|
35
|
+
s_mag[i] = 1e-10
|
|
36
|
+
# Convert to dBFS
|
|
37
|
+
s_dbfs = 20 * np.log10(s_mag/ref)
|
|
38
|
+
return freq, s_dbfs
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import miniaudio
|
|
2
|
+
import numpy as np
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
|
|
5
|
+
class BitBuffer:
|
|
6
|
+
def __init__(self, buf):
|
|
7
|
+
self.out = buf
|
|
8
|
+
self.bitbuffer = 0
|
|
9
|
+
self.bitbufferlen = 0
|
|
10
|
+
|
|
11
|
+
def write_int(self, n, val):
|
|
12
|
+
self.bitbuffer <<= n
|
|
13
|
+
self.bitbuffer |= val & ((1 << n) - 1)
|
|
14
|
+
self.bitbufferlen += n
|
|
15
|
+
while self.bitbufferlen >= 8:
|
|
16
|
+
self.bitbufferlen -= 8
|
|
17
|
+
b = (self.bitbuffer >> self.bitbufferlen) & 0xFF
|
|
18
|
+
self.out.write(bytes((b,)))
|
|
19
|
+
self.bitbuffer &= (1 << self.bitbufferlen) - 1
|
|
20
|
+
|
|
21
|
+
def close(self):
|
|
22
|
+
self.out.close()
|
|
23
|
+
|
|
24
|
+
def __enter__(self):
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, type, value, traceback):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
def add_header(buf, BLOCK_SIZE, NUMCHANNEL=1, BITDEPTH=24, SAMPLEDATALENGTH=4096, stream_data=0):
|
|
31
|
+
# Stream
|
|
32
|
+
buf.write_int(32, 0x664C6143) # fLaC
|
|
33
|
+
# METADATA_BLOCK_HEADER
|
|
34
|
+
buf.write_int(1, 1)
|
|
35
|
+
buf.write_int(7, 0)
|
|
36
|
+
buf.write_int(24, 34)
|
|
37
|
+
# METADATA_BLOCK_STREAMINFO
|
|
38
|
+
buf.write_int(16, BLOCK_SIZE * 3) # BLOCK SIZE
|
|
39
|
+
buf.write_int(16, BLOCK_SIZE * 3) # BLOCK SIZE
|
|
40
|
+
buf.write_int(24, 0) # Frame size
|
|
41
|
+
buf.write_int(24, 0) # Frame size
|
|
42
|
+
buf.write_int(20, 2**16) # SAMPLERATE
|
|
43
|
+
buf.write_int(3, NUMCHANNEL - 1) # NumChannels
|
|
44
|
+
buf.write_int(5, BITDEPTH - 1) # BIT DEPTH
|
|
45
|
+
buf.write_int(36, SAMPLEDATALENGTH)
|
|
46
|
+
for _ in range(16):
|
|
47
|
+
buf.write_int(8, 0)
|
|
48
|
+
|
|
49
|
+
# End flac header begin of writing data
|
|
50
|
+
for t in stream_data:
|
|
51
|
+
buf.write_int(8, t)
|
|
52
|
+
|
|
53
|
+
def decode(flac_stream, calibrationFactor):
|
|
54
|
+
# Add the header in front of the encoded samples
|
|
55
|
+
fp = BytesIO()
|
|
56
|
+
with BitBuffer(fp) as buf:
|
|
57
|
+
add_header(buf, BLOCK_SIZE=flac_stream.frame_length, stream_data = flac_stream.frame)
|
|
58
|
+
fp.seek(0)
|
|
59
|
+
# Decode samples
|
|
60
|
+
tmp = miniaudio.flac_read_s32(fp.read())
|
|
61
|
+
# now the samples is scaled to Pa
|
|
62
|
+
tmp = (np.array(tmp.samples) >> 8) # 32 bit -> 24 bit
|
|
63
|
+
samples = tmp * calibrationFactor
|
|
64
|
+
return samples
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import ssl
|
|
2
|
+
import requests
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from requests.adapters import HTTPAdapter
|
|
5
|
+
|
|
6
|
+
_CERT_FILE = Path(__file__).resolve().parent / "webxi_root.pem"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WebxiAdapter(HTTPAdapter):
|
|
10
|
+
"""Trusts only webxi_root.pem; skips hostname check since device certs are per-serial, not per-IP."""
|
|
11
|
+
|
|
12
|
+
def init_poolmanager(self, *args, **kwargs):
|
|
13
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
14
|
+
ctx.load_verify_locations(cafile=str(_CERT_FILE))
|
|
15
|
+
ctx.check_hostname = False
|
|
16
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
17
|
+
kwargs["ssl_context"] = ctx
|
|
18
|
+
kwargs["assert_hostname"] = False # urllib3 does its own SAN check independent of ssl_context.check_hostname
|
|
19
|
+
return super().init_poolmanager(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
def build_ssl_context():
|
|
22
|
+
"""SSLContext trusting only webxi_root.pem, with hostname checking disabled."""
|
|
23
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
24
|
+
ctx.load_verify_locations(cafile=str(_CERT_FILE))
|
|
25
|
+
ctx.check_hostname = False
|
|
26
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
27
|
+
return ctx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_session = requests.Session()
|
|
31
|
+
if _CERT_FILE.is_file():
|
|
32
|
+
_session.mount("https://", WebxiAdapter())
|
|
33
|
+
|
|
34
|
+
class UseHttps:
|
|
35
|
+
use_https = True # class attribute: shared, module-wide default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __init__(self, use_https=None):
|
|
40
|
+
if use_https is not None:
|
|
41
|
+
UseHttps.use_https = use_https
|
|
42
|
+
|
|
43
|
+
def request(self, method, host, endpoint, **kwargs):
|
|
44
|
+
"""Send a request to the SLM. HTTPS calls are verified against webxi_root.pem via WebxiAdapter."""
|
|
45
|
+
if UseHttps.use_https:
|
|
46
|
+
if host.startswith("http://"):
|
|
47
|
+
stripped_host = host.removeprefix("http://")
|
|
48
|
+
url = f"https://{stripped_host}{endpoint}"
|
|
49
|
+
elif host.startswith("https://"):
|
|
50
|
+
url = host + endpoint
|
|
51
|
+
else:
|
|
52
|
+
url = f"https://{host}{endpoint}"
|
|
53
|
+
else:
|
|
54
|
+
stripped_host = host.removeprefix("https://").removeprefix("http://")
|
|
55
|
+
url = f"http://{stripped_host}{endpoint}"
|
|
56
|
+
|
|
57
|
+
kwargs.setdefault("timeout", 10)
|
|
58
|
+
kwargs.setdefault("verify", str(_CERT_FILE))
|
|
59
|
+
|
|
60
|
+
# print(f"[https_requests] {method} {url}")
|
|
61
|
+
response = _session.request(method, url, **kwargs)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get(host, endpoint, **kwargs):
|
|
67
|
+
return UseHttps().request("GET", host, endpoint, **kwargs)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def put(host, endpoint, **kwargs):
|
|
71
|
+
return UseHttps().request("PUT", host, endpoint, **kwargs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def post(host, endpoint, **kwargs):
|
|
75
|
+
return UseHttps().request("POST", host, endpoint, **kwargs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def delete(host, endpoint, **kwargs):
|
|
79
|
+
return UseHttps().request("DELETE", host, endpoint, **kwargs)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from . import https_requests
|
|
2
|
+
|
|
3
|
+
def start_pause_measurement(hostID,start):
|
|
4
|
+
"""This function starts or pause a measurement.
|
|
5
|
+
If trying to start a measurement and the device is already running
|
|
6
|
+
will this function do nothing, same for pause."""
|
|
7
|
+
response = https_requests.get(hostID, "/webxi/Applications/SLM/State")
|
|
8
|
+
print(response.json())
|
|
9
|
+
if start and response.json() != "Running":
|
|
10
|
+
response = https_requests.put(hostID, "/WebXi/Applications/SLM?Action=StartPause")
|
|
11
|
+
assert(response.status_code == 200)
|
|
12
|
+
elif not start and response.json() == "Running":
|
|
13
|
+
response = https_requests.put(hostID, "/WebXi/Applications/SLM?Action=StartPause")
|
|
14
|
+
assert(response.status_code == 200)
|
|
15
|
+
|
|
16
|
+
def stop_measurement(hostID):
|
|
17
|
+
"""Stop a measurement on the device"""
|
|
18
|
+
response = https_requests.put(hostID, "/WebXi/Applications/SLM?Action=Stop")
|
|
19
|
+
assert(response.status_code == 200)
|
|
20
|
+
|