dosview 0.1.25__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.
- dosview/__init__.py +2236 -0
- dosview/_generated/__init__.py +3 -0
- dosview/_generated/eeprom_layout.py +42 -0
- dosview/_generated/xdos_devices.py +43 -0
- dosview/airdos04.py +978 -0
- dosview/airdos04_info.py +399 -0
- dosview/calibration_widget.py +1297 -0
- dosview/eeprom_schema.py +213 -0
- dosview/eeprom_widget.py +718 -0
- dosview/loading_dialog.py +118 -0
- dosview/parsers.py +348 -0
- dosview/rtc_widget.py +389 -0
- dosview/version.py +3 -0
- dosview-0.1.25.dist-info/METADATA +770 -0
- dosview-0.1.25.dist-info/RECORD +19 -0
- dosview-0.1.25.dist-info/WHEEL +5 -0
- dosview-0.1.25.dist-info/entry_points.txt +3 -0
- dosview-0.1.25.dist-info/licenses/LICENSE +674 -0
- dosview-0.1.25.dist-info/top_level.txt +1 -0
dosview/__init__.py
ADDED
|
@@ -0,0 +1,2236 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from PyQt5 import QtNetwork
|
|
6
|
+
from PyQt5.QtNetwork import QLocalSocket, QLocalServer
|
|
7
|
+
from PyQt5.QtCore import QThread, pyqtSignal, QSettings
|
|
8
|
+
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QHBoxLayout, QFormLayout
|
|
9
|
+
from PyQt5.QtWidgets import QPushButton, QFileDialog, QTreeWidget, QTreeWidgetItem, QAction, QSplitter, QTableWidgetItem
|
|
10
|
+
from PyQt5.QtGui import QIcon
|
|
11
|
+
|
|
12
|
+
import pyqtgraph as pg
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from PyQt5.QtCore import *
|
|
20
|
+
from PyQt5.QtGui import *
|
|
21
|
+
from PyQt5.QtWidgets import *
|
|
22
|
+
|
|
23
|
+
import hid
|
|
24
|
+
import numpy as np
|
|
25
|
+
import os
|
|
26
|
+
import serial
|
|
27
|
+
import serial.tools.list_ports
|
|
28
|
+
|
|
29
|
+
from .version import __version__
|
|
30
|
+
from pyqtgraph import ImageView
|
|
31
|
+
|
|
32
|
+
from .calibration_widget import (
|
|
33
|
+
CALIBRATION_CSV_METADATA_KEY,
|
|
34
|
+
CalibrationTab,
|
|
35
|
+
summarize_device,
|
|
36
|
+
summarize_environment,
|
|
37
|
+
)
|
|
38
|
+
from .parsers import (
|
|
39
|
+
BaseLogParser,
|
|
40
|
+
Airdos04CLogParser,
|
|
41
|
+
OldLogParser,
|
|
42
|
+
get_parser_for_file,
|
|
43
|
+
parse_file,
|
|
44
|
+
)
|
|
45
|
+
from .eeprom_widget import EepromManagerWidget
|
|
46
|
+
from .rtc_widget import RTCManagerWidget
|
|
47
|
+
from .airdos04 import Airdos04Hardware, Airdos04Addresses
|
|
48
|
+
from .loading_dialog import LoadingDialog, LoadingContext
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SYMLOG_LINTHRESH = 1.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def symlog(v, linthresh: float = SYMLOG_LINTHRESH):
|
|
55
|
+
"""Symmetric-log transform: linear within +/-linthresh, log-like beyond.
|
|
56
|
+
|
|
57
|
+
Unlike a pure log scale this keeps zero and low/negative values visible,
|
|
58
|
+
which matters for count spectra full of empty channels.
|
|
59
|
+
"""
|
|
60
|
+
v = np.asarray(v, dtype=float)
|
|
61
|
+
return np.sign(v) * np.log10(1.0 + np.abs(v) / linthresh)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def symlog_inv(u, linthresh: float = SYMLOG_LINTHRESH):
|
|
65
|
+
"""Inverse of :func:`symlog` — map a transformed value back to data units."""
|
|
66
|
+
u = np.asarray(u, dtype=float)
|
|
67
|
+
return np.sign(u) * linthresh * (np.power(10.0, np.abs(u)) - 1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SymLogAxisItem(pg.AxisItem):
|
|
71
|
+
"""Axis that plots symlog-transformed values but labels them in data units."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *args, linthresh: float = SYMLOG_LINTHRESH, **kwargs):
|
|
74
|
+
super().__init__(*args, **kwargs)
|
|
75
|
+
self.linthresh = linthresh
|
|
76
|
+
|
|
77
|
+
def tickStrings(self, values, scale, spacing):
|
|
78
|
+
out = []
|
|
79
|
+
for u in values:
|
|
80
|
+
real = float(symlog_inv(u, self.linthresh))
|
|
81
|
+
if abs(real) >= 1000 or (real != 0 and abs(real) < 0.01):
|
|
82
|
+
out.append(f"{real:.0e}")
|
|
83
|
+
else:
|
|
84
|
+
out.append(f"{real:g}")
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LoadDataThread(QThread):
|
|
89
|
+
data_loaded = pyqtSignal(list)
|
|
90
|
+
|
|
91
|
+
def __init__(self, file_path):
|
|
92
|
+
QThread.__init__(self)
|
|
93
|
+
self.file_path = file_path
|
|
94
|
+
|
|
95
|
+
def run(self):
|
|
96
|
+
data = parse_file(self.file_path)
|
|
97
|
+
self.data_loaded.emit(data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RecomputeThread(QThread):
|
|
101
|
+
"""Off-GUI-thread recompute of windowed spectrum / channel-filtered evolution.
|
|
102
|
+
|
|
103
|
+
Keeps the heavy numpy summation off the event loop so the spinner animates and
|
|
104
|
+
the UI stays responsive.
|
|
105
|
+
"""
|
|
106
|
+
result_ready = pyqtSignal(object)
|
|
107
|
+
|
|
108
|
+
def __init__(self, matrix, time_rows=None, channel_cols=None):
|
|
109
|
+
QThread.__init__(self)
|
|
110
|
+
self.matrix = matrix
|
|
111
|
+
self.time_rows = time_rows
|
|
112
|
+
self.channel_cols = channel_cols
|
|
113
|
+
|
|
114
|
+
def run(self):
|
|
115
|
+
out = {}
|
|
116
|
+
if self.time_rows is not None:
|
|
117
|
+
lo, hi = self.time_rows
|
|
118
|
+
out['spectrum'] = self.matrix[lo:hi].sum(axis=0)
|
|
119
|
+
if self.channel_cols is not None:
|
|
120
|
+
lo, hi = self.channel_cols
|
|
121
|
+
out['evolution'] = self.matrix[:, lo:hi].sum(axis=1)
|
|
122
|
+
self.result_ready.emit(out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PlotCanvas(pg.GraphicsLayoutWidget):
|
|
127
|
+
WINDOW_SIZE = 20
|
|
128
|
+
|
|
129
|
+
def __init__(self, parent=None, file_path=None):
|
|
130
|
+
super().__init__(parent)
|
|
131
|
+
self.data = []
|
|
132
|
+
self.file_path = file_path
|
|
133
|
+
self.telemetry_lines = {'temperature_0': None, 'humidity_0': None, 'temperature_1': None, 'humidity_1': None, 'temperature_2': None, 'pressure_3': None,
|
|
134
|
+
'voltage': None, 'current': None, 'capacity_remaining': None, 'capacity_full': None, 'temperature': None}
|
|
135
|
+
# Whether the parameters/telemetry plot (row 2) is shown. Off by default —
|
|
136
|
+
# the default view is just evolution + spectrum.
|
|
137
|
+
self._param_plot_visible = False
|
|
138
|
+
self._plot_telemetry = None
|
|
139
|
+
self._has_telemetry_plot = False
|
|
140
|
+
# Guards re-entrancy between the two linked region selectors.
|
|
141
|
+
self._updating = False
|
|
142
|
+
self._evo_region = None
|
|
143
|
+
self._spec_region = None
|
|
144
|
+
|
|
145
|
+
def plot(self, data):
|
|
146
|
+
start_time = time.time()
|
|
147
|
+
|
|
148
|
+
self.data = data
|
|
149
|
+
|
|
150
|
+
self.clear()
|
|
151
|
+
self._plot_telemetry = None
|
|
152
|
+
self._has_telemetry_plot = False
|
|
153
|
+
self._evo_region = None
|
|
154
|
+
self._spec_region = None
|
|
155
|
+
|
|
156
|
+
# Raw arrays kept for index math (regions operate on data units, while the
|
|
157
|
+
# curves are drawn through the symlog transform).
|
|
158
|
+
self._time_axis_s = np.asarray(data[0], dtype=float)
|
|
159
|
+
self._time_axis_min = self._time_axis_s / 60.0
|
|
160
|
+
self._sums = np.asarray(data[1], dtype=float)
|
|
161
|
+
self._hist = np.asarray(data[2], dtype=float)
|
|
162
|
+
self._channels = np.arange(len(self._hist), dtype=float)
|
|
163
|
+
sm = data[5] if len(data) > 5 else None
|
|
164
|
+
if sm is not None and hasattr(sm, "ndim") and sm.ndim == 2 and sm.shape[0] >= 1 and sm.shape[1] > 0:
|
|
165
|
+
self._spectral_matrix = np.asarray(sm, dtype=float)
|
|
166
|
+
else:
|
|
167
|
+
self._spectral_matrix = None
|
|
168
|
+
|
|
169
|
+
self.plot_evolution = self.addPlot(row=0, col=0,
|
|
170
|
+
axisItems={'left': SymLogAxisItem(orientation='left')})
|
|
171
|
+
self.plot_spectrum = self.addPlot(row=1, col=0,
|
|
172
|
+
axisItems={'left': SymLogAxisItem(orientation='left'),
|
|
173
|
+
'bottom': SymLogAxisItem(orientation='bottom')})
|
|
174
|
+
|
|
175
|
+
self.plot_evolution.showGrid(x=True, y=True)
|
|
176
|
+
self.plot_evolution.setLabel("left", "Total count per exposition", units="#")
|
|
177
|
+
self.plot_evolution.setLabel("bottom", "Time", units="min")
|
|
178
|
+
|
|
179
|
+
self._curve_evolution = self.plot_evolution.plot([], [],
|
|
180
|
+
symbol='o', symbolPen='pink', name='Channel', pen=None)
|
|
181
|
+
pen = pg.mkPen(color="r", width=3)
|
|
182
|
+
self._curve_rolling_avg = self.plot_evolution.plot([], [], pen=pen)
|
|
183
|
+
self._set_evolution_curve(self._sums)
|
|
184
|
+
|
|
185
|
+
self._curve_spectrum = self.plot_spectrum.plot([], [],
|
|
186
|
+
pen="r", symbol='x', symbolPen='g',
|
|
187
|
+
symbolBrush=0.2, name="Energy")
|
|
188
|
+
self.plot_spectrum.setLabel("left", "Total count per channel", units="#")
|
|
189
|
+
self.plot_spectrum.setLabel("bottom", "Channel", units="#")
|
|
190
|
+
self.plot_spectrum.showGrid(x=True, y=True)
|
|
191
|
+
self._set_spectrum_curve(self._hist)
|
|
192
|
+
|
|
193
|
+
# Linked region selectors — only useful when we have a spectral matrix to
|
|
194
|
+
# recompute windowed spectra / channel-filtered evolution from.
|
|
195
|
+
if self._spectral_matrix is not None:
|
|
196
|
+
self._add_region_selectors()
|
|
197
|
+
|
|
198
|
+
if len(self.data) > 4 and self.data[4]:
|
|
199
|
+
self._add_telemetry_plot(self.data[4])
|
|
200
|
+
# Respect the checkbox: hide the parameters plot unless enabled.
|
|
201
|
+
self.set_param_plot_visible(self._param_plot_visible)
|
|
202
|
+
|
|
203
|
+
self._setup_hover()
|
|
204
|
+
|
|
205
|
+
print("PLOT DURATION ... ", time.time()-start_time)
|
|
206
|
+
|
|
207
|
+
def _setup_hover(self):
|
|
208
|
+
"""Crosshair + tooltip following the cursor over the evolution/spectrum plots."""
|
|
209
|
+
pen = pg.mkPen((150, 150, 150), width=1, style=Qt.DashLine)
|
|
210
|
+
self._hover_targets = []
|
|
211
|
+
for plot, kind in ((self.plot_evolution, 'evo'), (self.plot_spectrum, 'spec')):
|
|
212
|
+
vline = pg.InfiniteLine(angle=90, movable=False, pen=pen)
|
|
213
|
+
hline = pg.InfiniteLine(angle=0, movable=False, pen=pen)
|
|
214
|
+
label = pg.TextItem(color=(230, 230, 230), anchor=(0, 1),
|
|
215
|
+
fill=pg.mkBrush(0, 0, 0, 160))
|
|
216
|
+
for item in (vline, hline, label):
|
|
217
|
+
item.setVisible(False)
|
|
218
|
+
item.setZValue(100)
|
|
219
|
+
plot.addItem(item, ignoreBounds=True)
|
|
220
|
+
self._hover_targets.append((plot, kind, vline, hline, label))
|
|
221
|
+
self._hover_proxy = pg.SignalProxy(
|
|
222
|
+
self.scene().sigMouseMoved, rateLimit=60, slot=self._on_mouse_moved)
|
|
223
|
+
|
|
224
|
+
def _on_mouse_moved(self, evt):
|
|
225
|
+
pos = evt[0]
|
|
226
|
+
for plot, kind, vline, hline, label in self._hover_targets:
|
|
227
|
+
if not plot.sceneBoundingRect().contains(pos):
|
|
228
|
+
vline.setVisible(False)
|
|
229
|
+
hline.setVisible(False)
|
|
230
|
+
label.setVisible(False)
|
|
231
|
+
continue
|
|
232
|
+
mp = plot.getViewBox().mapSceneToView(pos)
|
|
233
|
+
x, y = mp.x(), mp.y()
|
|
234
|
+
if kind == 'evo':
|
|
235
|
+
if not len(self._time_axis_min):
|
|
236
|
+
continue
|
|
237
|
+
idx = int(np.argmin(np.abs(self._time_axis_min - x)))
|
|
238
|
+
t = float(self._time_axis_min[idx])
|
|
239
|
+
count = float(self._cur_sums[idx])
|
|
240
|
+
px, py = t, float(symlog(count))
|
|
241
|
+
text = f"t = {t:.2f} min\ncount = {count:.0f}"
|
|
242
|
+
else:
|
|
243
|
+
if not len(self._channels):
|
|
244
|
+
continue
|
|
245
|
+
ch = int(np.clip(round(symlog_inv(x)), 0, len(self._channels) - 1))
|
|
246
|
+
count = float(self._cur_spectrum[ch])
|
|
247
|
+
px, py = float(symlog(ch)), float(symlog(count))
|
|
248
|
+
text = f"channel = {ch}\ncount = {count:.0f}"
|
|
249
|
+
vline.setPos(px)
|
|
250
|
+
hline.setPos(py)
|
|
251
|
+
label.setText(text)
|
|
252
|
+
label.setPos(px, py)
|
|
253
|
+
for item in (vline, hline, label):
|
|
254
|
+
item.setVisible(True)
|
|
255
|
+
|
|
256
|
+
def _set_evolution_curve(self, sums):
|
|
257
|
+
"""Draw the evolution curve (and rolling average) in symlog-y."""
|
|
258
|
+
sums = np.asarray(sums, dtype=float)
|
|
259
|
+
self._cur_sums = sums # currently displayed values (for the hover tooltip)
|
|
260
|
+
self._curve_evolution.setData(self._time_axis_min, symlog(sums))
|
|
261
|
+
window_size = self.WINDOW_SIZE
|
|
262
|
+
if len(sums) >= window_size:
|
|
263
|
+
rolling_avg = np.convolve(sums, np.ones(window_size) / window_size, mode='valid')
|
|
264
|
+
self._curve_rolling_avg.setData(self._time_axis_min[window_size - 1:], symlog(rolling_avg))
|
|
265
|
+
else:
|
|
266
|
+
self._curve_rolling_avg.setData([], [])
|
|
267
|
+
|
|
268
|
+
def _set_spectrum_curve(self, spec):
|
|
269
|
+
"""Draw the spectrum curve in symlog on both axes."""
|
|
270
|
+
spec = np.asarray(spec, dtype=float)
|
|
271
|
+
self._cur_spectrum = spec # currently displayed values (for the hover tooltip)
|
|
272
|
+
self._curve_spectrum.setData(symlog(self._channels), symlog(spec))
|
|
273
|
+
|
|
274
|
+
def _add_region_selectors(self):
|
|
275
|
+
# Both regions are hidden by default and toggled on via buttons. Recompute
|
|
276
|
+
# is manual (the Recompute button) — dragging no longer triggers anything,
|
|
277
|
+
# which keeps the UI responsive on large spectral matrices.
|
|
278
|
+
# Time window in the evolution plot (x = minutes, linear axis).
|
|
279
|
+
t_min, t_max = float(self._time_axis_min[0]), float(self._time_axis_min[-1])
|
|
280
|
+
self._evo_region = pg.LinearRegionItem(values=(t_min, t_max), orientation='vertical')
|
|
281
|
+
self._evo_region.setZValue(-10)
|
|
282
|
+
self._evo_region.setVisible(False)
|
|
283
|
+
self.plot_evolution.addItem(self._evo_region)
|
|
284
|
+
|
|
285
|
+
# Channel window in the spectrum plot (x is symlog-transformed channel).
|
|
286
|
+
c_min, c_max = float(self._channels[0]), float(self._channels[-1])
|
|
287
|
+
self._spec_region = pg.LinearRegionItem(
|
|
288
|
+
values=(float(symlog(c_min)), float(symlog(c_max))), orientation='vertical')
|
|
289
|
+
self._spec_region.setZValue(-10)
|
|
290
|
+
self._spec_region.setVisible(False)
|
|
291
|
+
self.plot_spectrum.addItem(self._spec_region)
|
|
292
|
+
|
|
293
|
+
def has_spectral_matrix(self):
|
|
294
|
+
return getattr(self, "_spectral_matrix", None) is not None
|
|
295
|
+
|
|
296
|
+
def set_time_region_active(self, active):
|
|
297
|
+
if self._evo_region is not None:
|
|
298
|
+
self._evo_region.setVisible(active)
|
|
299
|
+
|
|
300
|
+
def set_channel_region_active(self, active):
|
|
301
|
+
if self._spec_region is not None:
|
|
302
|
+
self._spec_region.setVisible(active)
|
|
303
|
+
|
|
304
|
+
def time_region_rows(self):
|
|
305
|
+
"""Row range (row_lo, row_hi) selected by the evolution region, or None."""
|
|
306
|
+
if self._spectral_matrix is None or self._evo_region is None or not self._evo_region.isVisible():
|
|
307
|
+
return None
|
|
308
|
+
lo_min, hi_min = self._evo_region.getRegion()
|
|
309
|
+
row_lo = int(np.searchsorted(self._time_axis_s, lo_min * 60.0, side='left'))
|
|
310
|
+
row_hi = int(np.searchsorted(self._time_axis_s, hi_min * 60.0, side='right'))
|
|
311
|
+
if row_hi - row_lo < 1:
|
|
312
|
+
return None
|
|
313
|
+
return (row_lo, row_hi)
|
|
314
|
+
|
|
315
|
+
def channel_region_cols(self):
|
|
316
|
+
"""Channel range (ch_lo, ch_hi) selected by the spectrum region, or None."""
|
|
317
|
+
if self._spectral_matrix is None or self._spec_region is None or not self._spec_region.isVisible():
|
|
318
|
+
return None
|
|
319
|
+
u_lo, u_hi = self._spec_region.getRegion()
|
|
320
|
+
n = len(self._channels)
|
|
321
|
+
ch_lo = int(np.clip(np.floor(symlog_inv(u_lo)), 0, n))
|
|
322
|
+
ch_hi = int(np.clip(np.ceil(symlog_inv(u_hi)), 0, n))
|
|
323
|
+
if ch_hi - ch_lo < 1:
|
|
324
|
+
return None
|
|
325
|
+
return (ch_lo, ch_hi)
|
|
326
|
+
|
|
327
|
+
def apply_spectrum(self, spec):
|
|
328
|
+
self._set_spectrum_curve(spec)
|
|
329
|
+
|
|
330
|
+
def apply_evolution(self, sums):
|
|
331
|
+
self._set_evolution_curve(sums)
|
|
332
|
+
|
|
333
|
+
def set_param_plot_visible(self, visible):
|
|
334
|
+
"""Add/remove the parameters (telemetry) plot from the layout (row 2)."""
|
|
335
|
+
self._param_plot_visible = visible
|
|
336
|
+
if self._plot_telemetry is None:
|
|
337
|
+
return
|
|
338
|
+
in_layout = self.getItem(2, 0) is self._plot_telemetry
|
|
339
|
+
if visible and not in_layout:
|
|
340
|
+
self.addItem(self._plot_telemetry, row=2, col=0)
|
|
341
|
+
elif not visible and in_layout:
|
|
342
|
+
self.removeItem(self._plot_telemetry)
|
|
343
|
+
|
|
344
|
+
_telemetry_colors = {
|
|
345
|
+
"temperature_0": (220, 50, 50),
|
|
346
|
+
"humidity_0": (50, 100, 220),
|
|
347
|
+
"temperature_1": (220, 130, 50),
|
|
348
|
+
"humidity_1": (50, 180, 220),
|
|
349
|
+
"temperature_2": (180, 50, 180),
|
|
350
|
+
"pressure_3": (50, 200, 100),
|
|
351
|
+
"voltage": (240, 240, 50),
|
|
352
|
+
"current": (200, 50, 200),
|
|
353
|
+
"capacity_remaining": (100, 220, 150),
|
|
354
|
+
"capacity_full": (150, 150, 150),
|
|
355
|
+
"temperature": (220, 100, 100),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
def _add_telemetry_plot(self, telemetry):
|
|
359
|
+
plot_telemetry = self.addPlot(row=2, col=0)
|
|
360
|
+
plot_telemetry.showGrid(x=True, y=True)
|
|
361
|
+
plot_telemetry.setLabel("bottom", "Time", units="min")
|
|
362
|
+
plot_telemetry.addLegend()
|
|
363
|
+
for key, (t, vals) in telemetry.items():
|
|
364
|
+
pen = pg.mkPen(color=self._telemetry_colors.get(key, (200, 200, 200)), width=2)
|
|
365
|
+
line = plot_telemetry.plot(t / 60, vals, pen=pen, name=key)
|
|
366
|
+
self.telemetry_lines[key] = line
|
|
367
|
+
self._plot_telemetry = plot_telemetry
|
|
368
|
+
self._has_telemetry_plot = True
|
|
369
|
+
|
|
370
|
+
def update_data(self, data):
|
|
371
|
+
"""Update plot curves in-place without clearing (preserves zoom/pan state).
|
|
372
|
+
|
|
373
|
+
Falls back to a full plot() call if telemetry appears for the first time.
|
|
374
|
+
"""
|
|
375
|
+
if not hasattr(self, "_curve_spectrum"):
|
|
376
|
+
self.plot(data)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# If telemetry just arrived and we don't have a telemetry plot yet, do a
|
|
380
|
+
# full redraw so the third subplot is added.
|
|
381
|
+
has_telemetry = len(data) > 4 and data[4]
|
|
382
|
+
if has_telemetry and not self._has_telemetry_plot:
|
|
383
|
+
self.plot(data)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
self.data = data
|
|
387
|
+
# Refresh the raw arrays the linked selectors and transforms operate on.
|
|
388
|
+
self._time_axis_s = np.asarray(data[0], dtype=float)
|
|
389
|
+
self._time_axis_min = self._time_axis_s / 60.0
|
|
390
|
+
self._sums = np.asarray(data[1], dtype=float)
|
|
391
|
+
self._hist = np.asarray(data[2], dtype=float)
|
|
392
|
+
self._channels = np.arange(len(self._hist), dtype=float)
|
|
393
|
+
sm = data[5] if len(data) > 5 else None
|
|
394
|
+
if sm is not None and hasattr(sm, "ndim") and sm.ndim == 2 and sm.shape[0] >= 1 and sm.shape[1] > 0:
|
|
395
|
+
self._spectral_matrix = np.asarray(sm, dtype=float)
|
|
396
|
+
else:
|
|
397
|
+
self._spectral_matrix = None
|
|
398
|
+
|
|
399
|
+
self._set_evolution_curve(self._sums)
|
|
400
|
+
self._set_spectrum_curve(self._hist)
|
|
401
|
+
|
|
402
|
+
if has_telemetry:
|
|
403
|
+
for key, (t, vals) in data[4].items():
|
|
404
|
+
if self.telemetry_lines.get(key) is not None:
|
|
405
|
+
self.telemetry_lines[key].setData(t / 60, vals)
|
|
406
|
+
|
|
407
|
+
def telemetry_toggle(self, key, value):
|
|
408
|
+
if self.telemetry_lines[key] is not None:
|
|
409
|
+
self.telemetry_lines[key].setVisible(value)
|
|
410
|
+
|
|
411
|
+
import ft260
|
|
412
|
+
FT260HidDriver = ft260.FT260_I2C
|
|
413
|
+
# Enable verbose FT260 HID/I2C debugging
|
|
414
|
+
try:
|
|
415
|
+
ft260.set_debug(True)
|
|
416
|
+
except Exception as _e:
|
|
417
|
+
print(f"[dosview] Warning: could not enable ft260 debug: {_e}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class AIRDOS04CTRL(QThread):
|
|
421
|
+
"""
|
|
422
|
+
Qt thread for communication with the AIRDOS04 detector via HID/I2C.
|
|
423
|
+
|
|
424
|
+
Hardware operations are delegated to the Airdos04Hardware class.
|
|
425
|
+
This thread is responsible for:
|
|
426
|
+
- HID connect/disconnect
|
|
427
|
+
- Qt signals for the GUI
|
|
428
|
+
- Thread-safe hardware calls
|
|
429
|
+
"""
|
|
430
|
+
connected = pyqtSignal(bool)
|
|
431
|
+
connect = pyqtSignal(bool)
|
|
432
|
+
sendAirdosStatus = pyqtSignal(dict)
|
|
433
|
+
sendEepromData = pyqtSignal(dict) # Signal carrying EEPROM data
|
|
434
|
+
loadingStateChanged = pyqtSignal(bool, str) # (is_loading, message)
|
|
435
|
+
errorOccurred = pyqtSignal(str) # Signal for error messages
|
|
436
|
+
|
|
437
|
+
# USB HID identification
|
|
438
|
+
VID = 0x1209
|
|
439
|
+
PID = 0x7aa0
|
|
440
|
+
|
|
441
|
+
basic_params = {}
|
|
442
|
+
|
|
443
|
+
dev = None
|
|
444
|
+
ftdi = None
|
|
445
|
+
hw = None # Airdos04Hardware instance
|
|
446
|
+
|
|
447
|
+
def __init__(self):
|
|
448
|
+
QThread.__init__(self)
|
|
449
|
+
self.hw = None # Will be set on connect
|
|
450
|
+
self.dev_uart = None
|
|
451
|
+
|
|
452
|
+
def run(self):
|
|
453
|
+
# Main thread loop
|
|
454
|
+
self.connected.emit(False)
|
|
455
|
+
while True:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
@pyqtSlot()
|
|
459
|
+
def connectSlot(self, state=True, power_off=False):
|
|
460
|
+
print("Connecting to HID device... ", state)
|
|
461
|
+
if state:
|
|
462
|
+
self.loadingStateChanged.emit(True, "Connecting to device...")
|
|
463
|
+
|
|
464
|
+
hid_interface_i2c = None
|
|
465
|
+
hid_interface_uart = None
|
|
466
|
+
|
|
467
|
+
for hidDevice in hid.enumerate(0, 0):
|
|
468
|
+
print(hidDevice)
|
|
469
|
+
if hidDevice['vendor_id'] == self.VID and hidDevice['product_id'] == self.PID:
|
|
470
|
+
if hidDevice['interface_number'] == 0:
|
|
471
|
+
hid_interface_i2c = hidDevice
|
|
472
|
+
else:
|
|
473
|
+
hid_interface_uart = hidDevice
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if hid_interface_i2c is None or hid_interface_uart is None:
|
|
477
|
+
self.loadingStateChanged.emit(False, "")
|
|
478
|
+
self.errorOccurred.emit("AIRDOS device not found.\nPlease check that the device is connected via USB.")
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
self.dev = hid.device()
|
|
483
|
+
self.dev.open_path(hid_interface_i2c['path'])
|
|
484
|
+
|
|
485
|
+
self.dev_uart = hid.device()
|
|
486
|
+
self.dev_uart.open_path(hid_interface_uart['path'])
|
|
487
|
+
print("Connected to HID device", self.dev, self.dev_uart)
|
|
488
|
+
|
|
489
|
+
self.loadingStateChanged.emit(True, "Initializing device...")
|
|
490
|
+
|
|
491
|
+
self.dev.send_feature_report([0xA1, 0x20])
|
|
492
|
+
self.dev.send_feature_report([0xA1, 0x02, 0x01])
|
|
493
|
+
|
|
494
|
+
# Bind the already-open HID interface to the FT260_I2C driver
|
|
495
|
+
self.ftdi = FT260HidDriver(hid_device=self.dev)
|
|
496
|
+
|
|
497
|
+
# Initialise Airdos04Hardware — Qt-independent hardware interface
|
|
498
|
+
self.hw = Airdos04Hardware(self.ftdi)
|
|
499
|
+
|
|
500
|
+
# Switch I2C mux to USB side
|
|
501
|
+
self.hw.set_i2c_direction(to_usb=True)
|
|
502
|
+
|
|
503
|
+
# Enable battery charging
|
|
504
|
+
self.hw.enable_charging()
|
|
505
|
+
|
|
506
|
+
self.loadingStateChanged.emit(True, "Reading serial numbers...")
|
|
507
|
+
|
|
508
|
+
# Read serial numbers via the hw module
|
|
509
|
+
print("AIRDOS SN ... ")
|
|
510
|
+
try:
|
|
511
|
+
self.basic_params['sn_batdatunit'] = self.hw.read_serial_number_batdatunit()
|
|
512
|
+
print(self.basic_params['sn_batdatunit'])
|
|
513
|
+
except Exception as e:
|
|
514
|
+
print(f"Error reading BatDatUnit SN: {e}")
|
|
515
|
+
self.basic_params['sn_batdatunit'] = "N/A"
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
self.basic_params['sn_ustsipin'] = self.hw.read_serial_number_ustsipin()
|
|
519
|
+
print(self.basic_params['sn_ustsipin'])
|
|
520
|
+
except Exception as e:
|
|
521
|
+
print(f"Error reading USTSIPIN SN: {e}")
|
|
522
|
+
self.basic_params['sn_ustsipin'] = "N/A"
|
|
523
|
+
|
|
524
|
+
self.hw.set_i2c_direction(to_usb=False)
|
|
525
|
+
|
|
526
|
+
self.connected.emit(True)
|
|
527
|
+
|
|
528
|
+
# Automatically load sensor and EEPROM data after connect
|
|
529
|
+
self.get_all_data()
|
|
530
|
+
|
|
531
|
+
except Exception as e:
|
|
532
|
+
print(f"[I2C] Connection failed: {e}")
|
|
533
|
+
self.loadingStateChanged.emit(False, "")
|
|
534
|
+
self.dev = None
|
|
535
|
+
self.dev_uart = None
|
|
536
|
+
self.ftdi = None
|
|
537
|
+
self.hw = None
|
|
538
|
+
self.errorOccurred.emit(f"Connection failed:\n{e}")
|
|
539
|
+
|
|
540
|
+
else:
|
|
541
|
+
# Disconnect
|
|
542
|
+
if self.hw is not None:
|
|
543
|
+
self.hw.set_i2c_direction(to_usb=True)
|
|
544
|
+
|
|
545
|
+
# Power off charger if requested
|
|
546
|
+
if power_off:
|
|
547
|
+
self.hw.disable_charging_and_poweroff()
|
|
548
|
+
|
|
549
|
+
self.hw.set_i2c_direction(to_usb=False)
|
|
550
|
+
|
|
551
|
+
if self.dev is not None:
|
|
552
|
+
self.dev.close()
|
|
553
|
+
if hasattr(self, 'dev_uart') and self.dev_uart is not None:
|
|
554
|
+
self.dev_uart.close()
|
|
555
|
+
|
|
556
|
+
self.dev = None
|
|
557
|
+
self.dev_uart = None
|
|
558
|
+
self.ftdi = None
|
|
559
|
+
self.hw = None
|
|
560
|
+
self.connected.emit(False)
|
|
561
|
+
|
|
562
|
+
@pyqtSlot()
|
|
563
|
+
def get_airdos_status(self):
|
|
564
|
+
"""Read full AIRDOS04 status and emit signal with the data."""
|
|
565
|
+
if self.hw is None:
|
|
566
|
+
print("[I2C] Not connected; skipping status read")
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
self.hw.set_i2c_direction(to_usb=True)
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
# Use Airdos04Hardware.to_dict() for compatibility with the original API
|
|
573
|
+
data = self.hw.to_dict()
|
|
574
|
+
# Merge basic parameters (serial numbers read at connect time)
|
|
575
|
+
data.update(self.basic_params)
|
|
576
|
+
except Exception as e:
|
|
577
|
+
print(f"[I2C] Error reading status: {e}")
|
|
578
|
+
data = self.basic_params.copy()
|
|
579
|
+
finally:
|
|
580
|
+
self.hw.set_i2c_direction(to_usb=False)
|
|
581
|
+
|
|
582
|
+
print("Sending...", type(data))
|
|
583
|
+
print(data)
|
|
584
|
+
self.sendAirdosStatus.emit(data)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@pyqtSlot()
|
|
588
|
+
def reset_rtc_time(self):
|
|
589
|
+
"""Reset the RTC counter to zero."""
|
|
590
|
+
if self.hw is None:
|
|
591
|
+
print("[I2C] Not connected; skipping RTC reset")
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
self.hw.set_i2c_direction(to_usb=True)
|
|
595
|
+
try:
|
|
596
|
+
reset_time = self.hw.reset_rtc()
|
|
597
|
+
print(f"Time reset at: {reset_time}")
|
|
598
|
+
finally:
|
|
599
|
+
self.hw.set_i2c_direction(to_usb=False)
|
|
600
|
+
|
|
601
|
+
@pyqtSlot()
|
|
602
|
+
def get_all_data(self):
|
|
603
|
+
"""Load all data — sensors and EEPROM."""
|
|
604
|
+
self.loadingStateChanged.emit(True, "Loading sensors...")
|
|
605
|
+
self.get_airdos_status()
|
|
606
|
+
|
|
607
|
+
self.loadingStateChanged.emit(True, "Loading EEPROM...")
|
|
608
|
+
self.get_eeprom_data()
|
|
609
|
+
|
|
610
|
+
self.loadingStateChanged.emit(False, "")
|
|
611
|
+
|
|
612
|
+
@pyqtSlot()
|
|
613
|
+
def get_eeprom_data(self):
|
|
614
|
+
"""Read EEPROM data from detector and battery, then emit signal."""
|
|
615
|
+
if self.hw is None:
|
|
616
|
+
print("[I2C] Not connected; skipping EEPROM read")
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
from .eeprom_schema import unpack_record, TOTAL_SIZE
|
|
620
|
+
|
|
621
|
+
eeprom_data = {}
|
|
622
|
+
self.hw.set_i2c_direction(to_usb=True)
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
# Detector EEPROM
|
|
626
|
+
try:
|
|
627
|
+
det_data = self.hw.read_eeprom(TOTAL_SIZE, start_address=0, eeprom_address=self.hw.addr.eeprom)
|
|
628
|
+
det_record = unpack_record(det_data, verify_crc=False)
|
|
629
|
+
eeprom_data['detector'] = det_record.to_dict()
|
|
630
|
+
except Exception as e:
|
|
631
|
+
print(f"[EEPROM] Error reading detector EEPROM: {e}")
|
|
632
|
+
eeprom_data['detector'] = {'error': str(e)}
|
|
633
|
+
|
|
634
|
+
# Battery EEPROM
|
|
635
|
+
try:
|
|
636
|
+
bat_data = self.hw.read_eeprom(TOTAL_SIZE, start_address=0, eeprom_address=self.hw.addr.eeprom_bat)
|
|
637
|
+
bat_record = unpack_record(bat_data, verify_crc=False)
|
|
638
|
+
eeprom_data['battery'] = bat_record.to_dict()
|
|
639
|
+
except Exception as e:
|
|
640
|
+
print(f"[EEPROM] Error reading battery EEPROM: {e}")
|
|
641
|
+
eeprom_data['battery'] = {'error': str(e)}
|
|
642
|
+
|
|
643
|
+
finally:
|
|
644
|
+
self.hw.set_i2c_direction(to_usb=False)
|
|
645
|
+
|
|
646
|
+
self.sendEepromData.emit(eeprom_data)
|
|
647
|
+
|
|
648
|
+
class HIDUARTCommunicationThread(QThread):
|
|
649
|
+
connected = pyqtSignal(bool)
|
|
650
|
+
|
|
651
|
+
def __init__(self):
|
|
652
|
+
QThread.__init__(self)
|
|
653
|
+
# Initialize HID communication here
|
|
654
|
+
|
|
655
|
+
def run(self):
|
|
656
|
+
pass
|
|
657
|
+
# Implement HID communication logic here
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class USBStorageMonitoringThread(QThread):
|
|
661
|
+
connected = pyqtSignal(bool)
|
|
662
|
+
|
|
663
|
+
def __init__(self):
|
|
664
|
+
QThread.__init__(self)
|
|
665
|
+
# Initialize USB storage monitoring here
|
|
666
|
+
|
|
667
|
+
def run(self):
|
|
668
|
+
pass
|
|
669
|
+
# Implement USB storage monitoring logic here
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class UARTReaderThread(QThread):
|
|
673
|
+
"""
|
|
674
|
+
QThread that reads a live AIRDOS data stream from a serial/UART port.
|
|
675
|
+
|
|
676
|
+
Supports both output formats used across AIRDOS devices:
|
|
677
|
+
- Old format ($HIST, $AIRDOS, $ENV, …) — AIRDOS03 and older
|
|
678
|
+
- New v2 format ($DOS, $START, $STOP, $E, $ENV, …) — AIRDOS04C fw 2.x+
|
|
679
|
+
|
|
680
|
+
The format is auto-detected from the first recognised message type.
|
|
681
|
+
After each complete record ($HIST or $STOP) the accumulated data is
|
|
682
|
+
emitted via dataUpdated so that a LivePlotTab can refresh its graphs.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
connected = pyqtSignal(bool)
|
|
686
|
+
dataUpdated = pyqtSignal(list) # [time_axis, sums, hist, metadata]
|
|
687
|
+
errorOccurred = pyqtSignal(str)
|
|
688
|
+
|
|
689
|
+
def __init__(self, port: str, baud: int = 115200):
|
|
690
|
+
QThread.__init__(self)
|
|
691
|
+
self._port = port
|
|
692
|
+
self._baud = baud
|
|
693
|
+
self._running = False
|
|
694
|
+
self._ser = None
|
|
695
|
+
|
|
696
|
+
def run(self):
|
|
697
|
+
self._running = True
|
|
698
|
+
hist = np.zeros(65536, dtype=int)
|
|
699
|
+
time_axis = []
|
|
700
|
+
sums = []
|
|
701
|
+
spectral_records = []
|
|
702
|
+
metadata = {"log_runs_count": 0, "log_device_info": {}}
|
|
703
|
+
fmt = None # 'old' or 'v2'
|
|
704
|
+
env_records = []
|
|
705
|
+
|
|
706
|
+
# v2-specific inter-record state
|
|
707
|
+
current_hist = None
|
|
708
|
+
current_counts = 0
|
|
709
|
+
|
|
710
|
+
def _build_telemetry(env_recs):
|
|
711
|
+
if not env_recs:
|
|
712
|
+
return {}
|
|
713
|
+
ea = np.array(env_recs)
|
|
714
|
+
tel = {
|
|
715
|
+
"temperature_0": (ea[:, 0], ea[:, 1]),
|
|
716
|
+
"humidity_0": (ea[:, 0], ea[:, 2]),
|
|
717
|
+
}
|
|
718
|
+
if ea.shape[1] > 3:
|
|
719
|
+
tel["temperature_1"] = (ea[:, 0], ea[:, 3])
|
|
720
|
+
if ea.shape[1] > 4:
|
|
721
|
+
tel["humidity_1"] = (ea[:, 0], ea[:, 4])
|
|
722
|
+
if ea.shape[1] > 5:
|
|
723
|
+
tel["temperature_2"] = (ea[:, 0], ea[:, 5])
|
|
724
|
+
if ea.shape[1] > 6:
|
|
725
|
+
tel["pressure_3"] = (ea[:, 0], ea[:, 6])
|
|
726
|
+
return tel
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
self._ser = serial.Serial(self._port, self._baud, timeout=1)
|
|
730
|
+
self.connected.emit(True)
|
|
731
|
+
|
|
732
|
+
while self._running:
|
|
733
|
+
raw = self._ser.readline()
|
|
734
|
+
if not raw:
|
|
735
|
+
continue
|
|
736
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
737
|
+
if not line:
|
|
738
|
+
continue
|
|
739
|
+
parts = line.split(",")
|
|
740
|
+
msg = parts[0]
|
|
741
|
+
|
|
742
|
+
# --- Device header records (format-independent) ---
|
|
743
|
+
if msg == "$DOS" and len(parts) > 6:
|
|
744
|
+
metadata["log_device_info"]["DOS"] = {
|
|
745
|
+
"hw-model": parts[1],
|
|
746
|
+
"fw-version": parts[2],
|
|
747
|
+
"eeprom": parts[3] if len(parts) > 3 else "",
|
|
748
|
+
"fw-commit": parts[4] if len(parts) > 4 else "",
|
|
749
|
+
"fw-build_info": parts[5] if len(parts) > 5 else "",
|
|
750
|
+
"hw-sn": parts[6].strip() if len(parts) > 6 else "",
|
|
751
|
+
}
|
|
752
|
+
metadata["log_runs_count"] += 1
|
|
753
|
+
elif msg == "$ADC" and len(parts) >= 2:
|
|
754
|
+
metadata["log_device_info"]["ADC"] = {
|
|
755
|
+
"module-type": parts[1] if len(parts) > 1 else "",
|
|
756
|
+
"serial": parts[2].strip() if len(parts) > 2 else "",
|
|
757
|
+
"configuration": parts[3].strip() if len(parts) > 3 else "",
|
|
758
|
+
}
|
|
759
|
+
elif msg == "$DIG" and len(parts) >= 2:
|
|
760
|
+
metadata["log_device_info"]["DIG"] = {
|
|
761
|
+
"module-type": parts[1] if len(parts) > 1 else "",
|
|
762
|
+
"serial": parts[2].strip() if len(parts) > 2 else "",
|
|
763
|
+
"configuration": parts[3].strip() if len(parts) > 3 else "",
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
# --- Format detection ---
|
|
767
|
+
if fmt is None:
|
|
768
|
+
if msg in ("$HIST", "$AIRDOS"):
|
|
769
|
+
fmt = "old"
|
|
770
|
+
elif msg in ("$START", "$STOP"):
|
|
771
|
+
fmt = "v2"
|
|
772
|
+
elif msg == "$DOS" and len(parts) > 2 and parts[2].startswith("2."):
|
|
773
|
+
fmt = "v2"
|
|
774
|
+
|
|
775
|
+
# --- Old format ---
|
|
776
|
+
if fmt == "old":
|
|
777
|
+
if msg == "$AIRDOS" and len(parts) >= 4:
|
|
778
|
+
metadata["log_device_info"]["AIRDOS"] = {
|
|
779
|
+
"hw-model": parts[1] if len(parts) > 1 else "",
|
|
780
|
+
"detector": parts[2] if len(parts) > 2 else "",
|
|
781
|
+
"hw-sn": parts[3].strip() if len(parts) > 3 else "",
|
|
782
|
+
}
|
|
783
|
+
elif msg == "$ENV" and len(parts) >= 5:
|
|
784
|
+
try:
|
|
785
|
+
env_records.append((
|
|
786
|
+
float(parts[2]),
|
|
787
|
+
float(parts[3]),
|
|
788
|
+
float(parts[4]),
|
|
789
|
+
float(parts[5]) if len(parts) > 5 else float("nan"),
|
|
790
|
+
float(parts[6]) if len(parts) > 6 else float("nan"),
|
|
791
|
+
float(parts[7]) if len(parts) > 7 else float("nan"),
|
|
792
|
+
float(parts[8]) if len(parts) > 8 else float("nan"),
|
|
793
|
+
))
|
|
794
|
+
except ValueError:
|
|
795
|
+
pass
|
|
796
|
+
elif msg == "$HIST":
|
|
797
|
+
try:
|
|
798
|
+
t = float(parts[2])
|
|
799
|
+
channels = np.array(parts[8:], dtype=float).astype(int)
|
|
800
|
+
if len(channels) > len(hist):
|
|
801
|
+
hist = np.resize(hist, len(channels))
|
|
802
|
+
hist[:len(channels)] += channels
|
|
803
|
+
time_axis.append(t)
|
|
804
|
+
sums.append(int(channels.sum()))
|
|
805
|
+
spectral_records.append(channels.copy())
|
|
806
|
+
metadata["log_runs_count"] = len(time_axis)
|
|
807
|
+
sm = np.array(spectral_records)
|
|
808
|
+
self.dataUpdated.emit(
|
|
809
|
+
[np.array(time_axis), np.array(sums), hist.copy(), metadata, _build_telemetry(env_records), sm]
|
|
810
|
+
)
|
|
811
|
+
except (ValueError, IndexError):
|
|
812
|
+
pass
|
|
813
|
+
|
|
814
|
+
# --- V2 format ---
|
|
815
|
+
elif fmt == "v2":
|
|
816
|
+
if msg == "$ENV" and len(parts) >= 5:
|
|
817
|
+
try:
|
|
818
|
+
env_records.append((
|
|
819
|
+
float(parts[2]),
|
|
820
|
+
float(parts[3]),
|
|
821
|
+
float(parts[4]),
|
|
822
|
+
float(parts[5]) if len(parts) > 5 else float("nan"),
|
|
823
|
+
float(parts[6]) if len(parts) > 6 else float("nan"),
|
|
824
|
+
float(parts[7]) if len(parts) > 7 else float("nan"),
|
|
825
|
+
float(parts[8]) if len(parts) > 8 else float("nan"),
|
|
826
|
+
))
|
|
827
|
+
except ValueError:
|
|
828
|
+
pass
|
|
829
|
+
elif msg == "$START":
|
|
830
|
+
current_hist = np.zeros_like(hist)
|
|
831
|
+
current_counts = 0
|
|
832
|
+
elif msg == "$E" and current_hist is not None and len(parts) >= 3:
|
|
833
|
+
try:
|
|
834
|
+
ch = int(parts[2])
|
|
835
|
+
if 0 <= ch < len(current_hist):
|
|
836
|
+
current_hist[ch] += 1
|
|
837
|
+
current_counts += 1
|
|
838
|
+
except ValueError:
|
|
839
|
+
pass
|
|
840
|
+
elif msg == "$STOP" and current_hist is not None:
|
|
841
|
+
try:
|
|
842
|
+
for idx, val in enumerate(parts[5:]):
|
|
843
|
+
try:
|
|
844
|
+
current_hist[idx] += int(val)
|
|
845
|
+
except (ValueError, IndexError):
|
|
846
|
+
pass
|
|
847
|
+
spectral_records.append(current_hist.copy())
|
|
848
|
+
hist += current_hist
|
|
849
|
+
try:
|
|
850
|
+
t = float(parts[2])
|
|
851
|
+
except (ValueError, IndexError):
|
|
852
|
+
t = 0.0
|
|
853
|
+
if t == 0.0:
|
|
854
|
+
t = float(parts[1]) if len(parts) > 1 else float(len(time_axis))
|
|
855
|
+
time_axis.append(t)
|
|
856
|
+
sums.append(int(current_hist.sum()))
|
|
857
|
+
sm = np.array(spectral_records)
|
|
858
|
+
self.dataUpdated.emit(
|
|
859
|
+
[np.array(time_axis), np.array(sums), hist.copy(), metadata, _build_telemetry(env_records), sm]
|
|
860
|
+
)
|
|
861
|
+
except (ValueError, IndexError):
|
|
862
|
+
pass
|
|
863
|
+
current_hist = np.zeros_like(hist)
|
|
864
|
+
current_counts = 0
|
|
865
|
+
|
|
866
|
+
except (serial.SerialException, TypeError):
|
|
867
|
+
# TypeError happens when stop() closes the port while readline() is
|
|
868
|
+
# in progress (fd becomes None); treat it as a normal disconnect.
|
|
869
|
+
if self._running:
|
|
870
|
+
self.errorOccurred.emit("Serial port closed unexpectedly.")
|
|
871
|
+
finally:
|
|
872
|
+
if self._ser and self._ser.is_open:
|
|
873
|
+
self._ser.close()
|
|
874
|
+
self.connected.emit(False)
|
|
875
|
+
|
|
876
|
+
def stop(self):
|
|
877
|
+
self._running = False
|
|
878
|
+
if self._ser and self._ser.is_open:
|
|
879
|
+
self._ser.close()
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class LabdosConfigTab(QWidget):
|
|
883
|
+
def __init__(self):
|
|
884
|
+
super().__init__()
|
|
885
|
+
|
|
886
|
+
self.initUI()
|
|
887
|
+
|
|
888
|
+
def initUI(self):
|
|
889
|
+
# Create a QTabWidget
|
|
890
|
+
tab_widget = QTabWidget()
|
|
891
|
+
tab_widget.setTabPosition(QTabWidget.West) # Set the tab position to vertical
|
|
892
|
+
|
|
893
|
+
# Create the first tab - Realtime Data
|
|
894
|
+
realtime_tab = QWidget()
|
|
895
|
+
realtime_layout = QVBoxLayout()
|
|
896
|
+
|
|
897
|
+
firmware_tab = QWidget()
|
|
898
|
+
firmware_layout = QVBoxLayout()
|
|
899
|
+
|
|
900
|
+
# Add the tabs to the tab_widget
|
|
901
|
+
tab_widget.addTab(realtime_tab, "Realtime Data")
|
|
902
|
+
tab_widget.addTab(firmware_tab, "Firmware")
|
|
903
|
+
|
|
904
|
+
# Create a main layout for the LabdosConfigTab
|
|
905
|
+
main_layout = QVBoxLayout()
|
|
906
|
+
main_layout.addWidget(tab_widget)
|
|
907
|
+
|
|
908
|
+
# Set the main layout for the LabdosConfigTab
|
|
909
|
+
self.setLayout(main_layout)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
class AirdosConfigTab(QWidget):
|
|
914
|
+
requestOpenLiveTab = pyqtSignal(object, str) # (UARTReaderThread, port_name)
|
|
915
|
+
|
|
916
|
+
def __init__(self):
|
|
917
|
+
super().__init__()
|
|
918
|
+
self.uart_thread = None
|
|
919
|
+
|
|
920
|
+
self.i2c_thread = AIRDOS04CTRL()
|
|
921
|
+
self.i2c_thread.connected.connect(self.on_i2c_connected)
|
|
922
|
+
self.i2c_thread.sendAirdosStatus.connect(self.on_airdos_status)
|
|
923
|
+
self.i2c_thread.sendEepromData.connect(self.on_eeprom_data)
|
|
924
|
+
self.i2c_thread.loadingStateChanged.connect(self.on_loading_state)
|
|
925
|
+
self.i2c_thread.errorOccurred.connect(self.on_i2c_error)
|
|
926
|
+
self.i2c_thread.start()
|
|
927
|
+
|
|
928
|
+
#self.uart_thread = HIDUARTCommunicationThread().start()
|
|
929
|
+
#self.mass_thread = USBStorageMonitoringThread().start()
|
|
930
|
+
|
|
931
|
+
return self.initUI()
|
|
932
|
+
|
|
933
|
+
def on_i2c_connected(self, connected: bool = True):
|
|
934
|
+
self.i2c_connect_button.setEnabled(not connected)
|
|
935
|
+
self.i2c_disconnect_button.setEnabled(connected)
|
|
936
|
+
self.i2c_power_off_button.setEnabled(connected)
|
|
937
|
+
|
|
938
|
+
def on_i2c_connect(self):
|
|
939
|
+
pass
|
|
940
|
+
|
|
941
|
+
def on_i2c_disconnect(self):
|
|
942
|
+
pass
|
|
943
|
+
|
|
944
|
+
def on_uart_connect(self):
|
|
945
|
+
port = self.uart_port_combo.currentText()
|
|
946
|
+
if not port:
|
|
947
|
+
QMessageBox.warning(self, "No port selected", "Please select a serial port first.")
|
|
948
|
+
return
|
|
949
|
+
baud = int(self.uart_baud_combo.currentText())
|
|
950
|
+
self.uart_thread = UARTReaderThread(port, baud)
|
|
951
|
+
self.uart_thread.errorOccurred.connect(self.on_uart_error)
|
|
952
|
+
self.uart_thread.connected.connect(self._on_uart_connected_state)
|
|
953
|
+
self.uart_thread.start()
|
|
954
|
+
self.requestOpenLiveTab.emit(self.uart_thread, port)
|
|
955
|
+
|
|
956
|
+
def on_uart_disconnect(self):
|
|
957
|
+
if self.uart_thread is not None:
|
|
958
|
+
self.uart_thread.stop()
|
|
959
|
+
self.uart_thread.wait()
|
|
960
|
+
self.uart_thread = None
|
|
961
|
+
|
|
962
|
+
def _on_uart_connected_state(self, connected: bool):
|
|
963
|
+
self.uart_connect_button.setEnabled(not connected)
|
|
964
|
+
self.uart_disconnect_button.setEnabled(connected)
|
|
965
|
+
|
|
966
|
+
def on_uart_error(self, message: str):
|
|
967
|
+
QMessageBox.warning(self, "UART error", message)
|
|
968
|
+
|
|
969
|
+
def on_mass_connect(self):
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
def on_mass_disconnect(self):
|
|
973
|
+
pass
|
|
974
|
+
|
|
975
|
+
def on_airdos_status(self, status):
|
|
976
|
+
print("AIRDOS STATUS:")
|
|
977
|
+
print(status)
|
|
978
|
+
|
|
979
|
+
self._update_tree_with_data(self.i2c_parameters_tree, status)
|
|
980
|
+
|
|
981
|
+
def on_eeprom_data(self, eeprom_data):
|
|
982
|
+
"""Handler for EEPROM data."""
|
|
983
|
+
print("EEPROM DATA:")
|
|
984
|
+
print(eeprom_data)
|
|
985
|
+
|
|
986
|
+
self._update_tree_with_data(self.eeprom_tree, eeprom_data)
|
|
987
|
+
|
|
988
|
+
def on_i2c_error(self, message: str):
|
|
989
|
+
"""Handler for I2C connection errors."""
|
|
990
|
+
QMessageBox.warning(self, "Connection error", message)
|
|
991
|
+
|
|
992
|
+
def on_loading_state(self, is_loading: bool, message: str):
|
|
993
|
+
"""Handler for loading state changes."""
|
|
994
|
+
if is_loading:
|
|
995
|
+
# Show loading dialog
|
|
996
|
+
if not hasattr(self, '_loading_dialog') or self._loading_dialog is None:
|
|
997
|
+
self._loading_dialog = LoadingDialog(self, "Loading", message)
|
|
998
|
+
self._loading_dialog.start()
|
|
999
|
+
else:
|
|
1000
|
+
self._loading_dialog.set_message(message)
|
|
1001
|
+
if not self._loading_dialog.isVisible():
|
|
1002
|
+
self._loading_dialog.start()
|
|
1003
|
+
else:
|
|
1004
|
+
# Hide loading dialog
|
|
1005
|
+
if hasattr(self, '_loading_dialog') and self._loading_dialog is not None:
|
|
1006
|
+
self._loading_dialog.stop()
|
|
1007
|
+
self._loading_dialog = None
|
|
1008
|
+
|
|
1009
|
+
def _update_tree_with_data(self, tree: QTreeWidget, data: dict):
|
|
1010
|
+
"""Populate a tree widget with a nested data dictionary."""
|
|
1011
|
+
tree.clear()
|
|
1012
|
+
|
|
1013
|
+
def add_properties_to_tree(item, properties):
|
|
1014
|
+
for key, value in properties.items():
|
|
1015
|
+
if isinstance(value, dict):
|
|
1016
|
+
parent_item = QTreeWidgetItem([key])
|
|
1017
|
+
item.addChild(parent_item)
|
|
1018
|
+
add_properties_to_tree(parent_item, value)
|
|
1019
|
+
elif isinstance(value, (list, tuple)):
|
|
1020
|
+
parent_item = QTreeWidgetItem([key, f"[{len(value)} items]"])
|
|
1021
|
+
item.addChild(parent_item)
|
|
1022
|
+
for i, v in enumerate(value):
|
|
1023
|
+
if isinstance(v, dict):
|
|
1024
|
+
child = QTreeWidgetItem([f"[{i}]"])
|
|
1025
|
+
parent_item.addChild(child)
|
|
1026
|
+
add_properties_to_tree(child, v)
|
|
1027
|
+
else:
|
|
1028
|
+
child = QTreeWidgetItem([f"[{i}]", str(v)])
|
|
1029
|
+
parent_item.addChild(child)
|
|
1030
|
+
else:
|
|
1031
|
+
child_item = QTreeWidgetItem([key, str(value)])
|
|
1032
|
+
item.addChild(child_item)
|
|
1033
|
+
|
|
1034
|
+
for key, value in data.items():
|
|
1035
|
+
if isinstance(value, dict):
|
|
1036
|
+
parent_item = QTreeWidgetItem([key])
|
|
1037
|
+
tree.addTopLevelItem(parent_item)
|
|
1038
|
+
add_properties_to_tree(parent_item, value)
|
|
1039
|
+
elif isinstance(value, (list, tuple)):
|
|
1040
|
+
parent_item = QTreeWidgetItem([key, f"[{len(value)} items]"])
|
|
1041
|
+
tree.addTopLevelItem(parent_item)
|
|
1042
|
+
for i, v in enumerate(value):
|
|
1043
|
+
if isinstance(v, dict):
|
|
1044
|
+
child = QTreeWidgetItem([f"[{i}]"])
|
|
1045
|
+
parent_item.addChild(child)
|
|
1046
|
+
add_properties_to_tree(child, v)
|
|
1047
|
+
else:
|
|
1048
|
+
child = QTreeWidgetItem([f"[{i}]", str(v)])
|
|
1049
|
+
parent_item.addChild(child)
|
|
1050
|
+
else:
|
|
1051
|
+
tree.addTopLevelItem(QTreeWidgetItem([key, str(value)]))
|
|
1052
|
+
tree.expandAll()
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def initUI(self):
|
|
1056
|
+
splitter = QSplitter(Qt.Horizontal)
|
|
1057
|
+
|
|
1058
|
+
i2c_widget = QGroupBox("I2C")
|
|
1059
|
+
i2c_layout = QVBoxLayout()
|
|
1060
|
+
i2c_layout.setAlignment(Qt.AlignTop)
|
|
1061
|
+
i2c_widget.setLayout(i2c_layout)
|
|
1062
|
+
|
|
1063
|
+
i2c_layout_row_1 = QHBoxLayout()
|
|
1064
|
+
|
|
1065
|
+
self.i2c_connect_button = QPushButton("Connect")
|
|
1066
|
+
self.i2c_disconnect_button = QPushButton("Disconnect")
|
|
1067
|
+
self.i2c_disconnect_button.disabled = True
|
|
1068
|
+
self.i2c_connect_button.clicked.connect(lambda: self.i2c_thread.connectSlot(True))
|
|
1069
|
+
self.i2c_disconnect_button.clicked.connect(lambda: self.i2c_thread.connectSlot(False))
|
|
1070
|
+
|
|
1071
|
+
self.i2c_power_off_button = QPushButton("Power off and Disconnect")
|
|
1072
|
+
self.i2c_power_off_button.clicked.connect(lambda: self.i2c_thread.connectSlot(False, True))
|
|
1073
|
+
self.i2c_power_off_button.disabled = True
|
|
1074
|
+
|
|
1075
|
+
i2c_layout_row_1.addWidget(self.i2c_connect_button)
|
|
1076
|
+
i2c_layout_row_1.addWidget(self.i2c_disconnect_button)
|
|
1077
|
+
i2c_layout_row_1.addWidget(self.i2c_power_off_button)
|
|
1078
|
+
i2c_layout.addLayout(i2c_layout_row_1)
|
|
1079
|
+
|
|
1080
|
+
# Sensors tree
|
|
1081
|
+
sensors_label = QLabel("📊 Sensors")
|
|
1082
|
+
sensors_label.setStyleSheet("font-weight: bold; margin-top: 5px;")
|
|
1083
|
+
i2c_layout.addWidget(sensors_label)
|
|
1084
|
+
|
|
1085
|
+
self.i2c_parameters_tree = QTreeWidget()
|
|
1086
|
+
self.i2c_parameters_tree.setHeaderLabels(["Parameter", "Value"])
|
|
1087
|
+
i2c_layout.addWidget(self.i2c_parameters_tree)
|
|
1088
|
+
|
|
1089
|
+
# EEPROM tree
|
|
1090
|
+
eeprom_label = QLabel("💾 EEPROM")
|
|
1091
|
+
eeprom_label.setStyleSheet("font-weight: bold; margin-top: 5px;")
|
|
1092
|
+
i2c_layout.addWidget(eeprom_label)
|
|
1093
|
+
|
|
1094
|
+
self.eeprom_tree = QTreeWidget()
|
|
1095
|
+
self.eeprom_tree.setHeaderLabels(["Parameter", "Value"])
|
|
1096
|
+
i2c_layout.addWidget(self.eeprom_tree)
|
|
1097
|
+
|
|
1098
|
+
# Action buttons row
|
|
1099
|
+
i2c_actions_row = QHBoxLayout()
|
|
1100
|
+
|
|
1101
|
+
reload_button = QPushButton("🔄 Reload All")
|
|
1102
|
+
reload_button.clicked.connect(self.i2c_thread.get_all_data)
|
|
1103
|
+
i2c_actions_row.addWidget(reload_button)
|
|
1104
|
+
|
|
1105
|
+
rtc_button = QPushButton("⏱️ RTC Manager")
|
|
1106
|
+
rtc_button.clicked.connect(self.open_rtc_manager)
|
|
1107
|
+
i2c_actions_row.addWidget(rtc_button)
|
|
1108
|
+
|
|
1109
|
+
i2c_layout.addLayout(i2c_actions_row)
|
|
1110
|
+
|
|
1111
|
+
# EEPROM manager buttons row
|
|
1112
|
+
i2c_eeprom_row = QHBoxLayout()
|
|
1113
|
+
|
|
1114
|
+
eeprom_det_btn = QPushButton("📀 EEPROM (detector)")
|
|
1115
|
+
eeprom_bat_btn = QPushButton("🔋 EEPROM (battery)")
|
|
1116
|
+
eeprom_det_btn.clicked.connect(self.open_eeprom_manager_detector)
|
|
1117
|
+
eeprom_bat_btn.clicked.connect(self.open_eeprom_manager_battery)
|
|
1118
|
+
i2c_eeprom_row.addWidget(eeprom_det_btn)
|
|
1119
|
+
i2c_eeprom_row.addWidget(eeprom_bat_btn)
|
|
1120
|
+
|
|
1121
|
+
i2c_layout.addLayout(i2c_eeprom_row)
|
|
1122
|
+
|
|
1123
|
+
uart_widget = QGroupBox("UART")
|
|
1124
|
+
uart_layout = QVBoxLayout()
|
|
1125
|
+
uart_layout.setAlignment(Qt.AlignTop)
|
|
1126
|
+
uart_widget.setLayout(uart_layout)
|
|
1127
|
+
|
|
1128
|
+
# Port and baud rate selection row
|
|
1129
|
+
uart_port_row = QHBoxLayout()
|
|
1130
|
+
self.uart_port_combo = QComboBox()
|
|
1131
|
+
self.uart_port_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
1132
|
+
|
|
1133
|
+
def refresh_ports():
|
|
1134
|
+
self.uart_port_combo.clear()
|
|
1135
|
+
for port_info in serial.tools.list_ports.comports():
|
|
1136
|
+
self.uart_port_combo.addItem(port_info.device)
|
|
1137
|
+
|
|
1138
|
+
refresh_ports()
|
|
1139
|
+
refresh_btn = QPushButton("↺")
|
|
1140
|
+
refresh_btn.setMaximumWidth(30)
|
|
1141
|
+
refresh_btn.setToolTip("Refresh port list")
|
|
1142
|
+
refresh_btn.clicked.connect(refresh_ports)
|
|
1143
|
+
|
|
1144
|
+
self.uart_baud_combo = QComboBox()
|
|
1145
|
+
self.uart_baud_combo.addItems(["9600", "115200"])
|
|
1146
|
+
self.uart_baud_combo.setCurrentText("115200")
|
|
1147
|
+
self.uart_baud_combo.setMaximumWidth(80)
|
|
1148
|
+
|
|
1149
|
+
uart_port_row.addWidget(QLabel("Port:"))
|
|
1150
|
+
uart_port_row.addWidget(self.uart_port_combo)
|
|
1151
|
+
uart_port_row.addWidget(refresh_btn)
|
|
1152
|
+
uart_port_row.addWidget(QLabel("Baud:"))
|
|
1153
|
+
uart_port_row.addWidget(self.uart_baud_combo)
|
|
1154
|
+
uart_layout.addLayout(uart_port_row)
|
|
1155
|
+
|
|
1156
|
+
# Connect / Disconnect buttons
|
|
1157
|
+
uart_btn_row = QHBoxLayout()
|
|
1158
|
+
self.uart_connect_button = QPushButton("Connect")
|
|
1159
|
+
self.uart_disconnect_button = QPushButton("Disconnect")
|
|
1160
|
+
self.uart_disconnect_button.setEnabled(False)
|
|
1161
|
+
self.uart_connect_button.clicked.connect(self.on_uart_connect)
|
|
1162
|
+
self.uart_disconnect_button.clicked.connect(self.on_uart_disconnect)
|
|
1163
|
+
uart_btn_row.addWidget(self.uart_connect_button)
|
|
1164
|
+
uart_btn_row.addWidget(self.uart_disconnect_button)
|
|
1165
|
+
uart_layout.addLayout(uart_btn_row)
|
|
1166
|
+
|
|
1167
|
+
splitter.addWidget(i2c_widget)
|
|
1168
|
+
splitter.addWidget(uart_widget)
|
|
1169
|
+
|
|
1170
|
+
layout = QVBoxLayout()
|
|
1171
|
+
layout.addWidget(splitter)
|
|
1172
|
+
self.setLayout(layout)
|
|
1173
|
+
|
|
1174
|
+
def _open_eeprom_manager(self, read_addr: int, module_type: str = "detector"):
|
|
1175
|
+
from .eeprom_schema import TOTAL_SIZE
|
|
1176
|
+
|
|
1177
|
+
def _log_eeprom(kind, message, data=None, *, full=False):
|
|
1178
|
+
colors = {"read": "\x1b[32m", "write": "\x1b[33m", "info": "\x1b[36m"}
|
|
1179
|
+
prefix = f"[EEPROM][{kind.upper()}]"
|
|
1180
|
+
color = colors.get(kind, "")
|
|
1181
|
+
reset = "\x1b[0m" if color else ""
|
|
1182
|
+
print(f"{color}{prefix} {message}{reset}")
|
|
1183
|
+
if data:
|
|
1184
|
+
if full or len(data) <= 64:
|
|
1185
|
+
preview = " ".join(f"{b:02X}" for b in data)
|
|
1186
|
+
ellipsis = ""
|
|
1187
|
+
else:
|
|
1188
|
+
preview = " ".join(f"{b:02X}" for b in data[:32])
|
|
1189
|
+
ellipsis = " ..."
|
|
1190
|
+
print(f"{color}{prefix} DATA={preview}{ellipsis}{reset}")
|
|
1191
|
+
|
|
1192
|
+
if not self.i2c_thread or not self.i2c_thread.hw:
|
|
1193
|
+
# Graceful fallback: demo mode without device
|
|
1194
|
+
def read_device() -> bytes:
|
|
1195
|
+
# Return empty block of TOTAL_SIZE (unprogrammed EEPROM = 0xFF)
|
|
1196
|
+
_log_eeprom("info", "I2C not connected; starting demo mode")
|
|
1197
|
+
_log_eeprom("read", "Demo mode: returning synthetic 0xFF block", data=b'\xFF' * 16)
|
|
1198
|
+
return b'\xFF' * TOTAL_SIZE
|
|
1199
|
+
def write_device(blob: bytes) -> None:
|
|
1200
|
+
_log_eeprom(
|
|
1201
|
+
"write", f"Demo mode: would write {len(blob)} bytes", data=bytes(blob[:16])
|
|
1202
|
+
)
|
|
1203
|
+
read_sn = None
|
|
1204
|
+
else:
|
|
1205
|
+
hw = self.i2c_thread.hw
|
|
1206
|
+
|
|
1207
|
+
# SN adresa podle typu modulu:
|
|
1208
|
+
# detektor (USTSIPIN analogová deska) → an_eeprom_sn (0x5B)
|
|
1209
|
+
# battery (BatDatUnit hlavní deska) → eeprom_sn (0x58)
|
|
1210
|
+
if module_type == "detector":
|
|
1211
|
+
sn_addr = hw.addr.an_eeprom_sn
|
|
1212
|
+
else:
|
|
1213
|
+
sn_addr = hw.addr.eeprom_sn
|
|
1214
|
+
|
|
1215
|
+
def read_device() -> bytes:
|
|
1216
|
+
try:
|
|
1217
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1218
|
+
_log_eeprom("read", f"Reading {TOTAL_SIZE} bytes from EEPROM addr=0x{read_addr:02X}")
|
|
1219
|
+
|
|
1220
|
+
# Debug: read serial number
|
|
1221
|
+
try:
|
|
1222
|
+
sn = hw.read_serial_number(sn_addr)
|
|
1223
|
+
print(f"EEPROM SN (addr=0x{sn_addr:02X}): {hex(sn)}")
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
print(f"Warning: Could not read EEPROM SN: {e}")
|
|
1226
|
+
|
|
1227
|
+
# Read EEPROM data via Airdos04Hardware
|
|
1228
|
+
data = hw.read_eeprom(TOTAL_SIZE, start_address=0, eeprom_address=read_addr)
|
|
1229
|
+
_log_eeprom(
|
|
1230
|
+
"read",
|
|
1231
|
+
f"Total read {len(data)} bytes; sample={list(data[:8])}",
|
|
1232
|
+
data=bytes(data[:16]),
|
|
1233
|
+
)
|
|
1234
|
+
_log_eeprom("read", "Read sequence (all bytes)", data=bytes(data), full=True)
|
|
1235
|
+
return data
|
|
1236
|
+
finally:
|
|
1237
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1238
|
+
|
|
1239
|
+
def write_device(blob: bytes) -> None:
|
|
1240
|
+
try:
|
|
1241
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1242
|
+
_log_eeprom("write", f"Writing {len(blob)} bytes to addr=0x{read_addr:02X}", data=bytes(blob[:16]))
|
|
1243
|
+
_log_eeprom("write", "Write sequence (all bytes)", data=bytes(blob), full=True)
|
|
1244
|
+
|
|
1245
|
+
success = hw.write_eeprom(blob, start_address=0, eeprom_address=read_addr)
|
|
1246
|
+
if success:
|
|
1247
|
+
_log_eeprom("write", "Write completed successfully")
|
|
1248
|
+
else:
|
|
1249
|
+
_log_eeprom("write", "Write failed!")
|
|
1250
|
+
finally:
|
|
1251
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1252
|
+
|
|
1253
|
+
def read_sn() -> int:
|
|
1254
|
+
# I2C směr už nastavuje volající (read_device drive). Zde to pro
|
|
1255
|
+
# případ samostatného volání zajistíme explicitně.
|
|
1256
|
+
try:
|
|
1257
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1258
|
+
return hw.read_serial_number(sn_addr)
|
|
1259
|
+
finally:
|
|
1260
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1261
|
+
|
|
1262
|
+
dlg = QDialog(self)
|
|
1263
|
+
dlg.setWindowTitle(f"EEPROM Manager (addr=0x{read_addr:02X})")
|
|
1264
|
+
v = QVBoxLayout(dlg)
|
|
1265
|
+
|
|
1266
|
+
w = EepromManagerWidget(
|
|
1267
|
+
read_device=read_device,
|
|
1268
|
+
write_device=write_device,
|
|
1269
|
+
read_sn=read_sn,
|
|
1270
|
+
io_context=self.i2c_thread,
|
|
1271
|
+
module_type=module_type,
|
|
1272
|
+
)
|
|
1273
|
+
v.addWidget(w)
|
|
1274
|
+
btn_close = QPushButton("Close")
|
|
1275
|
+
btn_close.clicked.connect(dlg.accept)
|
|
1276
|
+
v.addWidget(btn_close)
|
|
1277
|
+
dlg.resize(900, 600)
|
|
1278
|
+
dlg.exec_()
|
|
1279
|
+
|
|
1280
|
+
def open_eeprom_manager_detector(self):
|
|
1281
|
+
"""Open EEPROM manager for the analogue board (USTSIPIN)."""
|
|
1282
|
+
if self.i2c_thread.hw:
|
|
1283
|
+
self._open_eeprom_manager(self.i2c_thread.hw.addr.an_eeprom, module_type="detector")
|
|
1284
|
+
else:
|
|
1285
|
+
self._open_eeprom_manager(0x53, module_type="detector") # fallback address
|
|
1286
|
+
|
|
1287
|
+
def open_eeprom_manager_battery(self):
|
|
1288
|
+
"""Open EEPROM manager for the BatDatUnit."""
|
|
1289
|
+
if self.i2c_thread.hw:
|
|
1290
|
+
self._open_eeprom_manager(self.i2c_thread.hw.addr.eeprom, module_type="battery")
|
|
1291
|
+
else:
|
|
1292
|
+
self._open_eeprom_manager(0x50, module_type="battery") # fallback address
|
|
1293
|
+
|
|
1294
|
+
def open_rtc_manager(self):
|
|
1295
|
+
"""Open the RTC manager for detector clock management."""
|
|
1296
|
+
if not self.i2c_thread or not self.i2c_thread.hw:
|
|
1297
|
+
QMessageBox.warning(
|
|
1298
|
+
self,
|
|
1299
|
+
"RTC Manager",
|
|
1300
|
+
"I2C is not connected. Please connect to the detector first."
|
|
1301
|
+
)
|
|
1302
|
+
return
|
|
1303
|
+
|
|
1304
|
+
hw = self.i2c_thread.hw
|
|
1305
|
+
|
|
1306
|
+
def read_rtc():
|
|
1307
|
+
try:
|
|
1308
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1309
|
+
return hw.read_rtc()
|
|
1310
|
+
finally:
|
|
1311
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1312
|
+
|
|
1313
|
+
def reset_rtc():
|
|
1314
|
+
try:
|
|
1315
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1316
|
+
return hw.reset_rtc()
|
|
1317
|
+
finally:
|
|
1318
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1319
|
+
|
|
1320
|
+
def sync_rtc():
|
|
1321
|
+
# Write calibration point to EEPROM (sync_time, sync_rtc_seconds)
|
|
1322
|
+
try:
|
|
1323
|
+
hw.set_i2c_direction(to_usb=True)
|
|
1324
|
+
return hw.sync_rtc()
|
|
1325
|
+
finally:
|
|
1326
|
+
hw.set_i2c_direction(to_usb=False)
|
|
1327
|
+
|
|
1328
|
+
dlg = QDialog(self)
|
|
1329
|
+
dlg.setWindowTitle("RTC Manager - AIRDOS04")
|
|
1330
|
+
v = QVBoxLayout(dlg)
|
|
1331
|
+
|
|
1332
|
+
w = RTCManagerWidget(
|
|
1333
|
+
read_rtc=read_rtc,
|
|
1334
|
+
reset_rtc=reset_rtc,
|
|
1335
|
+
sync_rtc=sync_rtc
|
|
1336
|
+
)
|
|
1337
|
+
w.show_raw_registers(True)
|
|
1338
|
+
v.addWidget(w)
|
|
1339
|
+
|
|
1340
|
+
btn_close = QPushButton("Close")
|
|
1341
|
+
btn_close.clicked.connect(dlg.accept)
|
|
1342
|
+
v.addWidget(btn_close)
|
|
1343
|
+
|
|
1344
|
+
dlg.resize(550, 550)
|
|
1345
|
+
dlg.exec_()
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
class DataSpectrumView(QWidget):
|
|
1349
|
+
|
|
1350
|
+
def __init__(self, parent, title="Spectrogram"):
|
|
1351
|
+
self.parent = parent
|
|
1352
|
+
self._title = title
|
|
1353
|
+
super(DataSpectrumView, self).__init__(parent)
|
|
1354
|
+
self.setWindowFlags(self.windowFlags() | Qt.Window)
|
|
1355
|
+
self.initUI()
|
|
1356
|
+
|
|
1357
|
+
def initUI(self):
|
|
1358
|
+
|
|
1359
|
+
self.setWindowTitle(self._title)
|
|
1360
|
+
self.setGeometry(100, 100, 400, 300)
|
|
1361
|
+
self.imv = pg.ImageView(view=pg.PlotItem())
|
|
1362
|
+
layout = QVBoxLayout()
|
|
1363
|
+
layout.addWidget(self.imv)
|
|
1364
|
+
self.setLayout(layout)
|
|
1365
|
+
|
|
1366
|
+
def plot_data(self, data):
|
|
1367
|
+
# Clear the plot widget
|
|
1368
|
+
self.imv.clear()
|
|
1369
|
+
|
|
1370
|
+
# Set the image data
|
|
1371
|
+
self.imv.setImage(np.where(data == 0, np.nan, data))
|
|
1372
|
+
#self.imv.autoLevels()
|
|
1373
|
+
#self.imv.autoRange()
|
|
1374
|
+
|
|
1375
|
+
self.imv.show()
|
|
1376
|
+
|
|
1377
|
+
self.imv.setPredefinedGradient('thermal')
|
|
1378
|
+
self.imv.getView().showGrid(True, True, 0.2)
|
|
1379
|
+
|
|
1380
|
+
# Invert the y-axis
|
|
1381
|
+
self.imv.getView().invertY(False)
|
|
1382
|
+
#self.imv.getView().setLogMode(x=False, y=True)
|
|
1383
|
+
|
|
1384
|
+
# Add axis labels
|
|
1385
|
+
#self.imv.setLabel('left', 'Y Axis')
|
|
1386
|
+
#self.imv.setLabel('bottom', 'X Axis')
|
|
1387
|
+
|
|
1388
|
+
class PlotTab(QWidget):
|
|
1389
|
+
def __init__(self):
|
|
1390
|
+
super().__init__()
|
|
1391
|
+
self.initUI()
|
|
1392
|
+
|
|
1393
|
+
def initUI(self):
|
|
1394
|
+
self.properties_tree = QTreeWidget()
|
|
1395
|
+
self.properties_tree.setColumnCount(2)
|
|
1396
|
+
self.properties_tree.setHeaderLabels(["Property", "Value"])
|
|
1397
|
+
|
|
1398
|
+
self.datalines_tree = QTreeWidget()
|
|
1399
|
+
self.datalines_tree.setColumnCount(1)
|
|
1400
|
+
self.datalines_tree.setHeaderLabels(["Units"])
|
|
1401
|
+
|
|
1402
|
+
# Checkbox above the parameter list: toggles the whole parameters panel
|
|
1403
|
+
# (the list itself + the parameters/telemetry graph). Off by default so
|
|
1404
|
+
# the default view is just evolution + spectrum.
|
|
1405
|
+
self.show_params_checkbox = QCheckBox("Show parameters")
|
|
1406
|
+
self.show_params_checkbox.setChecked(False)
|
|
1407
|
+
self.show_params_checkbox.toggled.connect(self._on_show_params_toggled)
|
|
1408
|
+
self.datalines_tree.setVisible(False)
|
|
1409
|
+
|
|
1410
|
+
params_panel = QWidget()
|
|
1411
|
+
params_layout = QVBoxLayout(params_panel)
|
|
1412
|
+
params_layout.setContentsMargins(0, 0, 0, 0)
|
|
1413
|
+
params_layout.addWidget(self.show_params_checkbox)
|
|
1414
|
+
params_layout.addWidget(self.datalines_tree)
|
|
1415
|
+
|
|
1416
|
+
self.open_img_view_button = QPushButton("Spectrogram")
|
|
1417
|
+
self.open_img_view_button.setMaximumHeight(20)
|
|
1418
|
+
self.open_img_view_button.clicked.connect(self.open_spectrogram_view)
|
|
1419
|
+
|
|
1420
|
+
self.upload_file_button = QPushButton("Upload file")
|
|
1421
|
+
self.upload_file_button.setMaximumHeight(20)
|
|
1422
|
+
self.upload_file_button.clicked.connect(lambda: UploadFileDialog().exec_())
|
|
1423
|
+
|
|
1424
|
+
self.export_csv_button = QPushButton("Export spectrum")
|
|
1425
|
+
self.export_csv_button.setMaximumHeight(20)
|
|
1426
|
+
self.export_csv_button.clicked.connect(self.export_spectrum_csv)
|
|
1427
|
+
self.export_csv_button.setEnabled(False)
|
|
1428
|
+
|
|
1429
|
+
# Region-selection controls: each checkbox reveals its LinearRegionItem;
|
|
1430
|
+
# Recompute applies the active selections (threaded, with a spinner).
|
|
1431
|
+
self.time_region_button = QCheckBox("Time → spectrum")
|
|
1432
|
+
self.time_region_button.setMaximumHeight(20)
|
|
1433
|
+
self.time_region_button.setEnabled(False)
|
|
1434
|
+
self.time_region_button.toggled.connect(
|
|
1435
|
+
lambda on: self.plot_canvas.set_time_region_active(on))
|
|
1436
|
+
|
|
1437
|
+
self.channel_region_button = QCheckBox("Channel → evolution")
|
|
1438
|
+
self.channel_region_button.setMaximumHeight(20)
|
|
1439
|
+
self.channel_region_button.setEnabled(False)
|
|
1440
|
+
self.channel_region_button.toggled.connect(
|
|
1441
|
+
lambda on: self.plot_canvas.set_channel_region_active(on))
|
|
1442
|
+
|
|
1443
|
+
self.recompute_button = QPushButton("Recompute")
|
|
1444
|
+
self.recompute_button.setMaximumHeight(20)
|
|
1445
|
+
self.recompute_button.setEnabled(False)
|
|
1446
|
+
self.recompute_button.clicked.connect(self.recompute)
|
|
1447
|
+
|
|
1448
|
+
log_view_widget = QWidget()
|
|
1449
|
+
|
|
1450
|
+
self.left_panel = QSplitter(Qt.Vertical)
|
|
1451
|
+
|
|
1452
|
+
self.left_panel.addWidget(params_panel)
|
|
1453
|
+
self.left_panel.addWidget(self.properties_tree)
|
|
1454
|
+
|
|
1455
|
+
vb = QHBoxLayout()
|
|
1456
|
+
vb.addWidget(self.open_img_view_button)
|
|
1457
|
+
vb.addWidget(self.upload_file_button)
|
|
1458
|
+
vb.addWidget(self.export_csv_button)
|
|
1459
|
+
|
|
1460
|
+
self.recompute_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
|
1461
|
+
|
|
1462
|
+
region_row = QHBoxLayout()
|
|
1463
|
+
region_row.addWidget(self.time_region_button)
|
|
1464
|
+
region_row.addWidget(self.channel_region_button)
|
|
1465
|
+
region_row.addWidget(self.recompute_button)
|
|
1466
|
+
region_row.addStretch(1)
|
|
1467
|
+
|
|
1468
|
+
# A QSplitter does not support setLayout() directly (it silently ignores
|
|
1469
|
+
# the layout), so the controls live in a real container widget added as a
|
|
1470
|
+
# splitter pane.
|
|
1471
|
+
controls_widget = QWidget()
|
|
1472
|
+
controls_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
|
|
1473
|
+
controls_layout = QVBoxLayout(controls_widget)
|
|
1474
|
+
controls_layout.setContentsMargins(0, 0, 0, 0)
|
|
1475
|
+
controls_layout.addLayout(vb)
|
|
1476
|
+
controls_layout.addLayout(region_row)
|
|
1477
|
+
self.left_panel.addWidget(controls_widget)
|
|
1478
|
+
# Keep the controls pane at its natural (one-row) height in the splitter.
|
|
1479
|
+
self.left_panel.setStretchFactor(self.left_panel.count() - 1, 0)
|
|
1480
|
+
self.left_panel.setCollapsible(self.left_panel.count() - 1, False)
|
|
1481
|
+
|
|
1482
|
+
self.logView_splitter = QSplitter(Qt.Horizontal)
|
|
1483
|
+
self.logView_splitter.addWidget(self.left_panel)
|
|
1484
|
+
#self.logView_splitter.addWidget(QWidget())
|
|
1485
|
+
|
|
1486
|
+
layout = QVBoxLayout()
|
|
1487
|
+
layout.addWidget(self.logView_splitter)
|
|
1488
|
+
self.setLayout(layout)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def open_file(self, file_path):
|
|
1492
|
+
self.file_path = file_path
|
|
1493
|
+
self.plot_canvas = PlotCanvas(self, file_path=self.file_path)
|
|
1494
|
+
self.logView_splitter.addWidget(self.plot_canvas)
|
|
1495
|
+
|
|
1496
|
+
# Keep the sidebar as narrow as its content allows (it shrinks to the
|
|
1497
|
+
# minimum size hint); the plot canvas takes all remaining width. Still
|
|
1498
|
+
# draggable wider by the user.
|
|
1499
|
+
self.logView_splitter.setStretchFactor(0, 0)
|
|
1500
|
+
self.logView_splitter.setStretchFactor(1, 1)
|
|
1501
|
+
self.logView_splitter.setSizes([1, 10000])
|
|
1502
|
+
|
|
1503
|
+
self.start_data_loading()
|
|
1504
|
+
|
|
1505
|
+
def start_data_loading(self):
|
|
1506
|
+
# open_file() runs before this PlotTab is added to the tab widget, so
|
|
1507
|
+
# self.window() would still return this (invisible, un-parented) tab and
|
|
1508
|
+
# a modal dialog parented to it never paints. Defer to the event loop so
|
|
1509
|
+
# the tab is mounted in the main window first, then parent to a visible
|
|
1510
|
+
# top-level window so the spinner shows reliably.
|
|
1511
|
+
QTimer.singleShot(0, self._start_data_loading)
|
|
1512
|
+
|
|
1513
|
+
def _start_data_loading(self):
|
|
1514
|
+
parent = self.window()
|
|
1515
|
+
if parent is self or not parent.isVisible():
|
|
1516
|
+
parent = QApplication.activeWindow()
|
|
1517
|
+
self._loading_dialog = LoadingDialog(parent, "Loading", "Loading log file…")
|
|
1518
|
+
self._loading_dialog.start()
|
|
1519
|
+
self.load_data_thread = LoadDataThread(self.file_path)
|
|
1520
|
+
self.load_data_thread.data_loaded.connect(self.on_data_loaded)
|
|
1521
|
+
self.load_data_thread.start()
|
|
1522
|
+
|
|
1523
|
+
def on_data_loaded(self, data):
|
|
1524
|
+
self.data = data # TODO>.. tohle do budoucna zrusit a nahradit tridou parseru..
|
|
1525
|
+
print("Data are fully loaded...")
|
|
1526
|
+
self.export_csv_button.setEnabled(True)
|
|
1527
|
+
self.plot_canvas.plot(data)
|
|
1528
|
+
print("After plot data canvas")
|
|
1529
|
+
|
|
1530
|
+
self.properties_tree.clear()
|
|
1531
|
+
|
|
1532
|
+
def add_properties_to_tree(item, properties):
|
|
1533
|
+
for key, value in properties.items():
|
|
1534
|
+
# Pokud je to uroven ve storomu
|
|
1535
|
+
if isinstance(value, dict):
|
|
1536
|
+
parent_item = QTreeWidgetItem([key])
|
|
1537
|
+
item.addChild(parent_item)
|
|
1538
|
+
add_properties_to_tree(parent_item, value)
|
|
1539
|
+
# Zobraz samotne hodnoty
|
|
1540
|
+
else:
|
|
1541
|
+
if key in ['internal_time_min', 'internal_time_max', 'log_duration']:
|
|
1542
|
+
value_td = datetime.timedelta(seconds=value)
|
|
1543
|
+
value = f"{value_td}, ({value} seconds)"
|
|
1544
|
+
child_item = QTreeWidgetItem([key, str(value)])
|
|
1545
|
+
item.addChild(child_item)
|
|
1546
|
+
|
|
1547
|
+
metadata = data[3]
|
|
1548
|
+
for key, value in metadata.items():
|
|
1549
|
+
if isinstance(value, dict):
|
|
1550
|
+
parent_item = QTreeWidgetItem([key])
|
|
1551
|
+
self.properties_tree.addTopLevelItem(parent_item)
|
|
1552
|
+
add_properties_to_tree(parent_item, value)
|
|
1553
|
+
else:
|
|
1554
|
+
self.properties_tree.addTopLevelItem(QTreeWidgetItem([key, str(value)]))
|
|
1555
|
+
|
|
1556
|
+
self.datalines_tree.clear()
|
|
1557
|
+
dataline_options = ['temperature_0', 'humidity_0', 'temperature_1', 'humidity_1', 'temperature_2', 'pressure_3', 'voltage', 'current', 'capacity_remaining', 'temperature']
|
|
1558
|
+
for option in dataline_options:
|
|
1559
|
+
child_item = QTreeWidgetItem([option])
|
|
1560
|
+
child_item.setCheckState(0, Qt.Checked)
|
|
1561
|
+
self.datalines_tree.addTopLevelItem(child_item)
|
|
1562
|
+
|
|
1563
|
+
self.datalines_tree.itemChanged.connect(lambda item, state: self.plot_canvas.telemetry_toggle(item.text(0), item.checkState(0) == Qt.Checked))
|
|
1564
|
+
self.datalines_tree.setMaximumHeight(self.datalines_tree.sizeHintForRow(0) * (self.datalines_tree.topLevelItemCount()+4))
|
|
1565
|
+
|
|
1566
|
+
self.properties_tree.expandAll()
|
|
1567
|
+
|
|
1568
|
+
# Apply the current checkbox state now that the telemetry plot exists.
|
|
1569
|
+
self.plot_canvas.set_param_plot_visible(self.show_params_checkbox.isChecked())
|
|
1570
|
+
|
|
1571
|
+
# Region selection / recompute only makes sense with a spectral matrix.
|
|
1572
|
+
has_matrix = self.plot_canvas.has_spectral_matrix()
|
|
1573
|
+
self.time_region_button.setEnabled(has_matrix)
|
|
1574
|
+
self.channel_region_button.setEnabled(has_matrix)
|
|
1575
|
+
self.recompute_button.setEnabled(has_matrix)
|
|
1576
|
+
|
|
1577
|
+
if getattr(self, "_loading_dialog", None) is not None:
|
|
1578
|
+
self._loading_dialog.stop()
|
|
1579
|
+
self._loading_dialog = None
|
|
1580
|
+
|
|
1581
|
+
def _on_show_params_toggled(self, checked):
|
|
1582
|
+
self.datalines_tree.setVisible(checked)
|
|
1583
|
+
if hasattr(self, "plot_canvas") and self.plot_canvas is not None:
|
|
1584
|
+
self.plot_canvas.set_param_plot_visible(checked)
|
|
1585
|
+
|
|
1586
|
+
def recompute(self):
|
|
1587
|
+
if not hasattr(self, "plot_canvas") or not self.plot_canvas.has_spectral_matrix():
|
|
1588
|
+
return
|
|
1589
|
+
treq = self.plot_canvas.time_region_rows()
|
|
1590
|
+
creq = self.plot_canvas.channel_region_cols()
|
|
1591
|
+
if treq is None and creq is None:
|
|
1592
|
+
return
|
|
1593
|
+
self._recompute_dialog = LoadingDialog(self.window(), "Recomputing", "Recomputing…")
|
|
1594
|
+
self._recompute_dialog.start()
|
|
1595
|
+
self._recompute_thread = RecomputeThread(
|
|
1596
|
+
self.plot_canvas._spectral_matrix, treq, creq)
|
|
1597
|
+
self._recompute_thread.result_ready.connect(self._on_recompute_done)
|
|
1598
|
+
self._recompute_thread.start()
|
|
1599
|
+
|
|
1600
|
+
def _on_recompute_done(self, out):
|
|
1601
|
+
if 'spectrum' in out:
|
|
1602
|
+
self.plot_canvas.apply_spectrum(out['spectrum'])
|
|
1603
|
+
if 'evolution' in out:
|
|
1604
|
+
self.plot_canvas.apply_evolution(out['evolution'])
|
|
1605
|
+
if getattr(self, "_recompute_dialog", None) is not None:
|
|
1606
|
+
self._recompute_dialog.stop()
|
|
1607
|
+
self._recompute_dialog = None
|
|
1608
|
+
|
|
1609
|
+
def open_spectrogram_view(self):
|
|
1610
|
+
if not hasattr(self, "data") or self.data is None or len(self.data) < 6:
|
|
1611
|
+
QMessageBox.warning(self, "Spectrogram", "No spectral data available.")
|
|
1612
|
+
return
|
|
1613
|
+
spectral_matrix = self.data[5]
|
|
1614
|
+
if spectral_matrix.ndim < 2 or spectral_matrix.shape[0] < 2:
|
|
1615
|
+
QMessageBox.warning(self, "Spectrogram", "Not enough records to display a spectrogram.")
|
|
1616
|
+
return
|
|
1617
|
+
# Reuse existing window instead of stacking multiple copies
|
|
1618
|
+
if hasattr(self, "_spectrogram_window") and self._spectrogram_window is not None:
|
|
1619
|
+
self._spectrogram_window.close()
|
|
1620
|
+
title = getattr(self, "_port", None)
|
|
1621
|
+
if not title:
|
|
1622
|
+
fp = getattr(self, "file_path", None)
|
|
1623
|
+
title = os.path.basename(fp) if fp else "Spectrogram"
|
|
1624
|
+
w = DataSpectrumView(self, title=f"Spectrogram — {title}")
|
|
1625
|
+
self._spectrogram_window = w
|
|
1626
|
+
w.show()
|
|
1627
|
+
w.plot_data(spectral_matrix)
|
|
1628
|
+
|
|
1629
|
+
def export_spectrum_csv(self):
|
|
1630
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
1631
|
+
self, "Export spectrum", "", "CSV files (*.csv)"
|
|
1632
|
+
)
|
|
1633
|
+
if not path:
|
|
1634
|
+
return
|
|
1635
|
+
if not path.endswith(".csv"):
|
|
1636
|
+
path += ".csv"
|
|
1637
|
+
import csv
|
|
1638
|
+
hist = self.data[2]
|
|
1639
|
+
metadata = self.data[3] if len(self.data) > 3 and isinstance(self.data[3], dict) else {}
|
|
1640
|
+
telemetry = self.data[4] if len(self.data) > 4 and isinstance(self.data[4], dict) else {}
|
|
1641
|
+
calibration_metadata = {
|
|
1642
|
+
"version": 1,
|
|
1643
|
+
"environment": summarize_environment(telemetry),
|
|
1644
|
+
"device": summarize_device(metadata),
|
|
1645
|
+
"source_metadata": metadata,
|
|
1646
|
+
}
|
|
1647
|
+
with open(path, "w", newline="") as f:
|
|
1648
|
+
writer = csv.writer(f)
|
|
1649
|
+
writer.writerow([
|
|
1650
|
+
CALIBRATION_CSV_METADATA_KEY,
|
|
1651
|
+
json.dumps(calibration_metadata, ensure_ascii=True, allow_nan=True),
|
|
1652
|
+
])
|
|
1653
|
+
writer.writerow(["channel", "counts"])
|
|
1654
|
+
for ch, cnt in enumerate(hist):
|
|
1655
|
+
writer.writerow([ch, int(cnt)])
|
|
1656
|
+
|
|
1657
|
+
def save_as(self):
|
|
1658
|
+
if not hasattr(self, "data") or self.data is None:
|
|
1659
|
+
return
|
|
1660
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
1661
|
+
self, "Save data", "", "NumPy archive (*.npz)"
|
|
1662
|
+
)
|
|
1663
|
+
if not path:
|
|
1664
|
+
return
|
|
1665
|
+
if not path.endswith(".npz"):
|
|
1666
|
+
path += ".npz"
|
|
1667
|
+
import json as _json
|
|
1668
|
+
data = self.data
|
|
1669
|
+
arrays = {
|
|
1670
|
+
"time_axis": data[0],
|
|
1671
|
+
"sums": data[1],
|
|
1672
|
+
"hist": data[2],
|
|
1673
|
+
"metadata": np.array(_json.dumps(data[3])),
|
|
1674
|
+
}
|
|
1675
|
+
if len(data) > 4 and data[4]:
|
|
1676
|
+
for key, (t, v) in data[4].items():
|
|
1677
|
+
arrays[f"telemetry_time_{key}"] = t
|
|
1678
|
+
arrays[f"telemetry_value_{key}"] = v
|
|
1679
|
+
if len(data) > 5 and data[5] is not None and hasattr(data[5], "shape") and data[5].ndim == 2:
|
|
1680
|
+
arrays["spectral_matrix"] = data[5]
|
|
1681
|
+
np.savez_compressed(path, **arrays)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
class LivePlotTab(PlotTab):
|
|
1685
|
+
"""
|
|
1686
|
+
Live-streaming variant of PlotTab.
|
|
1687
|
+
|
|
1688
|
+
Reuses the full PlotTab UI (graphs, metadata tree, telemetry toggles,
|
|
1689
|
+
export button). Instead of loading a file via LoadDataThread, data is
|
|
1690
|
+
fed incrementally by UARTReaderThread via on_data_updated().
|
|
1691
|
+
"""
|
|
1692
|
+
|
|
1693
|
+
def __init__(self, port: str):
|
|
1694
|
+
self._port = port
|
|
1695
|
+
super().__init__() # calls initUI(), builds all widgets
|
|
1696
|
+
# Set up PlotCanvas without a file path, just like open_file() would
|
|
1697
|
+
# but skipping the LoadDataThread step.
|
|
1698
|
+
self.plot_canvas = PlotCanvas(self)
|
|
1699
|
+
self.logView_splitter.addWidget(self.plot_canvas)
|
|
1700
|
+
self.logView_splitter.setSizes([1, 9])
|
|
1701
|
+
sizes = self.logView_splitter.sizes()
|
|
1702
|
+
sizes[0] = int(sizes[1] * 0.1)
|
|
1703
|
+
self.logView_splitter.setSizes(sizes)
|
|
1704
|
+
|
|
1705
|
+
def on_data_updated(self, data):
|
|
1706
|
+
"""Called by UARTReaderThread after each complete record."""
|
|
1707
|
+
if not getattr(self, "_live_initialized", False):
|
|
1708
|
+
self.on_data_loaded(data) # full setup: plots + metadata tree + telemetry checkboxes
|
|
1709
|
+
self._live_initialized = True
|
|
1710
|
+
else:
|
|
1711
|
+
self.data = data
|
|
1712
|
+
self.plot_canvas.update_data(data)
|
|
1713
|
+
|
|
1714
|
+
def on_uart_disconnected(self):
|
|
1715
|
+
pass # graphs and trees retain the last received data
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
class UploadFileDialog(QDialog):
|
|
1719
|
+
def __init__(self, parent=None):
|
|
1720
|
+
super().__init__()
|
|
1721
|
+
self._manager = QtNetwork.QNetworkAccessManager()
|
|
1722
|
+
self._manager.finished.connect(self.on_request_finished)
|
|
1723
|
+
self.initUI()
|
|
1724
|
+
|
|
1725
|
+
def initUI(self):
|
|
1726
|
+
self.setWindowTitle("Upload file")
|
|
1727
|
+
self.setGeometry(100, 100, 400, 300)
|
|
1728
|
+
self.layout = QVBoxLayout()
|
|
1729
|
+
self.setLayout(self.layout)
|
|
1730
|
+
|
|
1731
|
+
self.file_path = QLineEdit()
|
|
1732
|
+
self.record_name = QLineEdit()
|
|
1733
|
+
self.description = QTextEdit()
|
|
1734
|
+
self.time_tracked = QCheckBox("Time tracked")
|
|
1735
|
+
self.record_metadata = QTextEdit()
|
|
1736
|
+
|
|
1737
|
+
upload_button = QPushButton("Upload")
|
|
1738
|
+
upload_button.clicked.connect(self.upload_file)
|
|
1739
|
+
|
|
1740
|
+
lay = QFormLayout()
|
|
1741
|
+
lay.addRow("File path:", self.file_path)
|
|
1742
|
+
lay.addRow("Record name:", self.record_name)
|
|
1743
|
+
lay.addRow("Description:", self.description)
|
|
1744
|
+
lay.addRow("Time tracked:", self.time_tracked)
|
|
1745
|
+
lay.addRow("Record metadata:", self.record_metadata)
|
|
1746
|
+
lay.addRow(upload_button)
|
|
1747
|
+
|
|
1748
|
+
self.upload_button = QPushButton("Upload")
|
|
1749
|
+
self.upload_button.clicked.connect(self.upload_file)
|
|
1750
|
+
self.layout.addLayout(lay)
|
|
1751
|
+
|
|
1752
|
+
def upload_file(self):
|
|
1753
|
+
file_path = self.file_path.text()
|
|
1754
|
+
print("Uploading file", file_path)
|
|
1755
|
+
self.accept()
|
|
1756
|
+
|
|
1757
|
+
def on_request_finished(self, reply):
|
|
1758
|
+
print("Upload finished")
|
|
1759
|
+
self.accept()
|
|
1760
|
+
|
|
1761
|
+
@pyqtSlot()
|
|
1762
|
+
def upload(self):
|
|
1763
|
+
data = {
|
|
1764
|
+
"name": self.record_name.text(),
|
|
1765
|
+
"": ""
|
|
1766
|
+
}
|
|
1767
|
+
path = self.filepath_lineedit.text()
|
|
1768
|
+
files = {"image": path}
|
|
1769
|
+
multi_part = self.construct_multipart(data, files)
|
|
1770
|
+
if multi_part:
|
|
1771
|
+
url = Qt.QUrl("http://127.0.0.1:8100/api/record/")
|
|
1772
|
+
request = QtNetwork.QNetworkRequest(url)
|
|
1773
|
+
reply = self._manager.post(request, multi_part)
|
|
1774
|
+
multi_part.setParent(reply)
|
|
1775
|
+
|
|
1776
|
+
class PreferencesVindow(QDialog):
|
|
1777
|
+
def __init__(self):
|
|
1778
|
+
super().__init__()
|
|
1779
|
+
self.initUI()
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def DosportalTab(self):
|
|
1783
|
+
#self.dosportal_tab_group = QGroupBox("DOSPORTAL settings")
|
|
1784
|
+
self.dosportal_tab_layout = QVBoxLayout()
|
|
1785
|
+
settings = QSettings("UST", "dosview")
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
self.url = QLineEdit()
|
|
1789
|
+
self.login = QLineEdit()
|
|
1790
|
+
self.password = QLineEdit()
|
|
1791
|
+
|
|
1792
|
+
# Load data from QSettings
|
|
1793
|
+
url = settings.value("url")
|
|
1794
|
+
if url is not None:
|
|
1795
|
+
self.url.setText(url)
|
|
1796
|
+
login = settings.value("login")
|
|
1797
|
+
if login is not None:
|
|
1798
|
+
self.login.setText(login)
|
|
1799
|
+
|
|
1800
|
+
password = settings.value("password")
|
|
1801
|
+
self.password.setEchoMode(QLineEdit.Password)
|
|
1802
|
+
if password is not None:
|
|
1803
|
+
self.password.setText(password)
|
|
1804
|
+
|
|
1805
|
+
vb = QHBoxLayout()
|
|
1806
|
+
vb.addWidget(QLabel("URL"))
|
|
1807
|
+
vb.addWidget(self.url)
|
|
1808
|
+
self.dosportal_tab_layout.addLayout(vb)
|
|
1809
|
+
|
|
1810
|
+
vb = QHBoxLayout()
|
|
1811
|
+
vb.addWidget(QLabel("Login"))
|
|
1812
|
+
vb.addWidget(self.login)
|
|
1813
|
+
self.dosportal_tab_layout.addLayout(vb)
|
|
1814
|
+
|
|
1815
|
+
vb = QHBoxLayout()
|
|
1816
|
+
vb.addWidget(QLabel("Password"))
|
|
1817
|
+
vb.addWidget(self.password)
|
|
1818
|
+
self.dosportal_tab_layout.addLayout(vb)
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
# Save data to QSettings
|
|
1822
|
+
def save_settings():
|
|
1823
|
+
settings.setValue("url", self.url.text())
|
|
1824
|
+
settings.setValue("login", self.login.text())
|
|
1825
|
+
settings.setValue("password", self.password.text())
|
|
1826
|
+
|
|
1827
|
+
# Connect save button to save_settings function
|
|
1828
|
+
save_button = QPushButton("Save credentials")
|
|
1829
|
+
save_button.clicked.connect(save_settings)
|
|
1830
|
+
|
|
1831
|
+
test_button = QPushButton("Test connection")
|
|
1832
|
+
test_button.clicked.connect(lambda: print("Testing connection .... not implemented yet :-) "))
|
|
1833
|
+
|
|
1834
|
+
vb = QHBoxLayout()
|
|
1835
|
+
vb.addWidget(save_button)
|
|
1836
|
+
vb.addWidget(test_button)
|
|
1837
|
+
|
|
1838
|
+
self.dosportal_tab_layout.addLayout(vb)
|
|
1839
|
+
|
|
1840
|
+
self.dosportal_tab_layout.addStretch(1)
|
|
1841
|
+
return self.dosportal_tab_layout
|
|
1842
|
+
#self.dosportal_tab_group.setLayout(self.dosportal_tab_layout)
|
|
1843
|
+
#return self.dosportal_tab_group
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def initUI(self):
|
|
1847
|
+
|
|
1848
|
+
self.setWindowTitle("DOSVIEW Preferences")
|
|
1849
|
+
self.setGeometry(100, 100, 400, 300)
|
|
1850
|
+
self.layout = QVBoxLayout()
|
|
1851
|
+
self.setLayout(self.layout)
|
|
1852
|
+
|
|
1853
|
+
self.tabs = QTabWidget()
|
|
1854
|
+
self.layout.addWidget(self.tabs)
|
|
1855
|
+
|
|
1856
|
+
self.dosportal_tab = QWidget()
|
|
1857
|
+
#self.dosportal_tab_layout = QVBoxLayout()
|
|
1858
|
+
self.dosportal_tab.setLayout( self.DosportalTab() )
|
|
1859
|
+
|
|
1860
|
+
self.tabs.addTab(self.dosportal_tab, "DOSPORTAL")
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
self.tabs.addTab(QWidget(), "Advanced")
|
|
1865
|
+
#self.layout.addWidget(QPushButton("Save"))
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
class App(QMainWindow):
|
|
1869
|
+
def __init__(self, args):
|
|
1870
|
+
super().__init__()
|
|
1871
|
+
self.args = args
|
|
1872
|
+
self.left = 100
|
|
1873
|
+
self.top = 100
|
|
1874
|
+
self.settings = QSettings("UST", "dosview")
|
|
1875
|
+
self.title = 'dosview'
|
|
1876
|
+
self.width = 640
|
|
1877
|
+
self.height = 400
|
|
1878
|
+
self.file_path = args.file_path
|
|
1879
|
+
self.initUI()
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
self.plot_tab = None
|
|
1883
|
+
self.airdos_tab = None
|
|
1884
|
+
|
|
1885
|
+
self.solve_startup_args()
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def solve_startup_args(self):
|
|
1889
|
+
|
|
1890
|
+
if self.args.file_path:
|
|
1891
|
+
print("Oteviram zalozku s logem")
|
|
1892
|
+
self.openPlotTab()
|
|
1893
|
+
|
|
1894
|
+
if self.args.airdos:
|
|
1895
|
+
print("Oteviram zalozku s airdosem")
|
|
1896
|
+
self.openAirdosTab()
|
|
1897
|
+
|
|
1898
|
+
if self.args.labdos:
|
|
1899
|
+
print("Oteviram zalozku s labdosem")
|
|
1900
|
+
self.openLabdosTab()
|
|
1901
|
+
|
|
1902
|
+
if self.args.calibration:
|
|
1903
|
+
print("Oteviram zalozku s kalibraci")
|
|
1904
|
+
self.openCalibrationTab()
|
|
1905
|
+
|
|
1906
|
+
def updateStackedWidget(self):
|
|
1907
|
+
print("Updating stacked widget")
|
|
1908
|
+
print(self.tab_widget.count())
|
|
1909
|
+
if self.tab_widget.count():
|
|
1910
|
+
self.stacked_container.setCurrentIndex(1)
|
|
1911
|
+
else:
|
|
1912
|
+
self.stacked_container.setCurrentIndex(0)
|
|
1913
|
+
|
|
1914
|
+
def close_tab(self, index):
|
|
1915
|
+
widget = self.tab_widget.widget(index)
|
|
1916
|
+
if widget is None:
|
|
1917
|
+
return
|
|
1918
|
+
# Close pyqtgraph canvas before removing from the widget tree to avoid
|
|
1919
|
+
# use-after-free crashes in the Qt scene (ViewBox holds a live C++ ref).
|
|
1920
|
+
if hasattr(widget, "plot_canvas") and widget.plot_canvas is not None:
|
|
1921
|
+
widget.plot_canvas.close()
|
|
1922
|
+
self.tab_widget.removeTab(index)
|
|
1923
|
+
widget.deleteLater()
|
|
1924
|
+
self.updateStackedWidget()
|
|
1925
|
+
|
|
1926
|
+
def openPlotTab(self, file_path = None):
|
|
1927
|
+
plot_tab = PlotTab()
|
|
1928
|
+
if not file_path:
|
|
1929
|
+
file_path = self.args.file_path
|
|
1930
|
+
print("Oteviram log.. ", file_path)
|
|
1931
|
+
|
|
1932
|
+
plot_tab.open_file(file_path)
|
|
1933
|
+
file_name = os.path.basename(file_path)
|
|
1934
|
+
|
|
1935
|
+
tab_index = self.tab_widget.addTab(plot_tab, file_name)
|
|
1936
|
+
self.tab_widget.setTabToolTip(tab_index, file_path)
|
|
1937
|
+
self.tab_widget.setCurrentIndex(tab_index)
|
|
1938
|
+
self.updateStackedWidget()
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def openAirdosTab(self):
|
|
1942
|
+
airdos_tab = AirdosConfigTab()
|
|
1943
|
+
airdos_tab.requestOpenLiveTab.connect(self.open_live_tab)
|
|
1944
|
+
self.tab_widget.addTab(airdos_tab, "Airdos control")
|
|
1945
|
+
self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1)
|
|
1946
|
+
self.updateStackedWidget()
|
|
1947
|
+
|
|
1948
|
+
def open_live_tab(self, uart_thread, port: str):
|
|
1949
|
+
live_tab = LivePlotTab(port)
|
|
1950
|
+
uart_thread.dataUpdated.connect(live_tab.on_data_updated)
|
|
1951
|
+
uart_thread.connected.connect(
|
|
1952
|
+
lambda state, tab=live_tab: tab.on_uart_disconnected() if not state else None
|
|
1953
|
+
)
|
|
1954
|
+
tab_index = self.tab_widget.addTab(live_tab, f"AIRDOS live [{port}]")
|
|
1955
|
+
self.tab_widget.setCurrentIndex(tab_index)
|
|
1956
|
+
self.updateStackedWidget()
|
|
1957
|
+
|
|
1958
|
+
def openLabdosTab(self):
|
|
1959
|
+
labdos_tab = LabdosConfigTab()
|
|
1960
|
+
self.tab_widget.addTab(labdos_tab, "Labdos control")
|
|
1961
|
+
self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1)
|
|
1962
|
+
self.updateStackedWidget()
|
|
1963
|
+
|
|
1964
|
+
def openCalibrationTab(self, preload_path=None):
|
|
1965
|
+
calibration_tab = CalibrationTab()
|
|
1966
|
+
tab_index = self.tab_widget.addTab(calibration_tab, "Calibration")
|
|
1967
|
+
self.tab_widget.setCurrentIndex(tab_index)
|
|
1968
|
+
self.updateStackedWidget()
|
|
1969
|
+
|
|
1970
|
+
def _on_calibration_title_changed(full_path, idx=tab_index):
|
|
1971
|
+
name = os.path.basename(full_path)
|
|
1972
|
+
prefix = "*" if calibration_tab._dirty else ""
|
|
1973
|
+
self.tab_widget.setTabText(idx, f"{prefix}{name}")
|
|
1974
|
+
self.tab_widget.setTabToolTip(idx, full_path)
|
|
1975
|
+
if self.tab_widget.currentIndex() == idx:
|
|
1976
|
+
self.statusBar.showMessage(full_path)
|
|
1977
|
+
|
|
1978
|
+
def _on_calibration_dirty_changed(is_dirty, idx=tab_index, tab=calibration_tab):
|
|
1979
|
+
name = os.path.basename(tab._project_path) if tab._project_path else "Calibration"
|
|
1980
|
+
self.tab_widget.setTabText(idx, f"*{name}" if is_dirty else name)
|
|
1981
|
+
|
|
1982
|
+
calibration_tab.titleChanged.connect(_on_calibration_title_changed)
|
|
1983
|
+
calibration_tab.dirtyChanged.connect(_on_calibration_dirty_changed)
|
|
1984
|
+
|
|
1985
|
+
if preload_path:
|
|
1986
|
+
calibration_tab.load_project(path=preload_path)
|
|
1987
|
+
|
|
1988
|
+
def blank_page(self):
|
|
1989
|
+
# This is widget for blank page
|
|
1990
|
+
# When no tab is opened
|
|
1991
|
+
widget = QWidget()
|
|
1992
|
+
layout = QVBoxLayout()
|
|
1993
|
+
label = QLabel("No tab is opened yet. Open a file or enable airdos control.", alignment=Qt.AlignCenter)
|
|
1994
|
+
layout.addWidget(label)
|
|
1995
|
+
widget.setLayout(layout)
|
|
1996
|
+
return widget
|
|
1997
|
+
|
|
1998
|
+
def initUI(self):
|
|
1999
|
+
self.setWindowTitle(self.title)
|
|
2000
|
+
self.setGeometry(self.left, self.top, self.width, self.height)
|
|
2001
|
+
self.setWindowIcon(QIcon('media/icon_ust.png'))
|
|
2002
|
+
|
|
2003
|
+
self.restoreGeometry(self.settings.value("geometry", self.saveGeometry()))
|
|
2004
|
+
self.restoreState(self.settings.value("windowState", self.saveState()))
|
|
2005
|
+
|
|
2006
|
+
self.tab_widget = QTabWidget()
|
|
2007
|
+
|
|
2008
|
+
self.tab_widget.setCurrentIndex(0)
|
|
2009
|
+
self.tab_widget.setTabsClosable(True)
|
|
2010
|
+
self.tab_widget.setMovable(True)
|
|
2011
|
+
self.tab_widget.tabCloseRequested.connect(self.close_tab)
|
|
2012
|
+
|
|
2013
|
+
bar = self.menuBar()
|
|
2014
|
+
file = bar.addMenu("&File")
|
|
2015
|
+
|
|
2016
|
+
open = QAction("Open",self)
|
|
2017
|
+
open.setShortcut("Ctrl+O")
|
|
2018
|
+
open.triggered.connect(self.open_new_file)
|
|
2019
|
+
file.addAction(open)
|
|
2020
|
+
|
|
2021
|
+
self.save_action = QAction("Save", self)
|
|
2022
|
+
self.save_action.setShortcut("Ctrl+S")
|
|
2023
|
+
self.save_action.triggered.connect(self.save_current_tab)
|
|
2024
|
+
self.save_action.setEnabled(False)
|
|
2025
|
+
file.addAction(self.save_action)
|
|
2026
|
+
|
|
2027
|
+
self.save_as_action = QAction("Save As", self)
|
|
2028
|
+
self.save_as_action.setShortcut("Ctrl+Shift+S")
|
|
2029
|
+
self.save_as_action.triggered.connect(self.save_current_tab_as)
|
|
2030
|
+
self.save_as_action.setEnabled(False)
|
|
2031
|
+
file.addAction(self.save_as_action)
|
|
2032
|
+
|
|
2033
|
+
self.tab_widget.currentChanged.connect(self._update_save_action)
|
|
2034
|
+
|
|
2035
|
+
tools = bar.addMenu("&Tools")
|
|
2036
|
+
|
|
2037
|
+
preferences = QAction("Preferences", self)
|
|
2038
|
+
preferences.triggered.connect(lambda: PreferencesVindow().exec())
|
|
2039
|
+
tools.addAction(preferences)
|
|
2040
|
+
|
|
2041
|
+
tool_airdosctrl = QAction("AirdosControl", self)
|
|
2042
|
+
tool_airdosctrl.triggered.connect(self.action_switch_airdoscontrol)
|
|
2043
|
+
tools.addAction(tool_airdosctrl)
|
|
2044
|
+
|
|
2045
|
+
tools_labdosctrl = QAction("LabdosControl", self)
|
|
2046
|
+
tools_labdosctrl.triggered.connect(self.action_switch_labdoscontrol)
|
|
2047
|
+
tools.addAction(tools_labdosctrl)
|
|
2048
|
+
|
|
2049
|
+
tool_calibration = QAction("Calibration", self)
|
|
2050
|
+
tool_calibration.triggered.connect(self.action_switch_calibration)
|
|
2051
|
+
tools.addAction(tool_calibration)
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
help = bar.addMenu("&Help")
|
|
2055
|
+
doc = QAction("Documentation", self)
|
|
2056
|
+
help.addAction(doc)
|
|
2057
|
+
doc.triggered.connect(lambda: QDesktopServices.openUrl(QUrl("https://docs.dos.ust.cz/dosview/")))
|
|
2058
|
+
|
|
2059
|
+
gith = QAction("GitHub repository", self)
|
|
2060
|
+
help.addAction(gith)
|
|
2061
|
+
gith.triggered.connect(lambda: QDesktopServices.openUrl(QUrl("https://github.com/UniversalScientificTechnologies/dosview/")))
|
|
2062
|
+
|
|
2063
|
+
about = QAction("About", self)
|
|
2064
|
+
help.addAction(about)
|
|
2065
|
+
about.triggered.connect(self.about)
|
|
2066
|
+
|
|
2067
|
+
self.statusBar = QStatusBar()
|
|
2068
|
+
self.setStatusBar(self.statusBar)
|
|
2069
|
+
self.statusBar.showMessage("Welcome to dosview")
|
|
2070
|
+
|
|
2071
|
+
self.stacked_container = QStackedWidget()
|
|
2072
|
+
self.stacked_container.addWidget(self.blank_page())
|
|
2073
|
+
self.stacked_container.addWidget(self.tab_widget)
|
|
2074
|
+
self.stacked_container.setCurrentIndex(0)
|
|
2075
|
+
self.setCentralWidget(self.stacked_container)
|
|
2076
|
+
|
|
2077
|
+
self.show()
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def action_switch_airdoscontrol(self):
|
|
2081
|
+
self.openAirdosTab()
|
|
2082
|
+
|
|
2083
|
+
def action_switch_labdoscontrol(self):
|
|
2084
|
+
self.openLabdosTab()
|
|
2085
|
+
|
|
2086
|
+
def action_switch_calibration(self):
|
|
2087
|
+
self.openCalibrationTab()
|
|
2088
|
+
|
|
2089
|
+
import sys
|
|
2090
|
+
import datetime
|
|
2091
|
+
from PyQt5.QtCore import QT_VERSION_STR
|
|
2092
|
+
from PyQt5.QtWidgets import QMessageBox
|
|
2093
|
+
from PyQt5.QtGui import QPixmap
|
|
2094
|
+
|
|
2095
|
+
def about(self):
|
|
2096
|
+
about_text = f"""
|
|
2097
|
+
<b>dosview</b><br>
|
|
2098
|
+
<b>Version:</b> {__version__}<br>
|
|
2099
|
+
<br>
|
|
2100
|
+
Universal Scientific Technologies, s.r.o.<br>
|
|
2101
|
+
<a href="https://www.ust.cz/about/">www.ust.cz/</a><br>
|
|
2102
|
+
<br>
|
|
2103
|
+
<b>Description:</b><br>
|
|
2104
|
+
dosview is a utility for visualization and analysis of data from UST's <a href="https://docs.dos.ust.cz/">dosimeters and spectrometers</a>.<br>
|
|
2105
|
+
<br>
|
|
2106
|
+
<b>Support:</b> <a href="mailto:support@ust.cz">support@ust.cz</a><br>
|
|
2107
|
+
<br>
|
|
2108
|
+
<b> <a href="https://github.com/UniversalScientificTechnologies/dosview/issues">Report an issue to GitHub Issues</a><br>
|
|
2109
|
+
<br>
|
|
2110
|
+
<b>Source code:</b> <a href="https://github.com/UniversalScientificTechnologies/dosview/">GitHub repository</a><br>
|
|
2111
|
+
<br>
|
|
2112
|
+
<b>Technical info:</b><br>
|
|
2113
|
+
Python: {sys.version.split()[0]}<br>
|
|
2114
|
+
Qt: {QT_VERSION_STR}<br>
|
|
2115
|
+
Build date: {datetime.datetime.now().strftime("%Y-%m-%d")}<br>
|
|
2116
|
+
<br>
|
|
2117
|
+
<b>License:</b> GPL-3.0 License<br>
|
|
2118
|
+
© 2025 Universal Scientific Technologies, s.r.o.<br>
|
|
2119
|
+
"""
|
|
2120
|
+
dlg = QMessageBox(self)
|
|
2121
|
+
dlg.setWindowTitle("About dosview")
|
|
2122
|
+
dlg.setTextFormat(Qt.TextFormat.RichText)
|
|
2123
|
+
dlg.setText(about_text)
|
|
2124
|
+
dlg.setIconPixmap(QPixmap("media/icon_ust.png").scaled(64, 64))
|
|
2125
|
+
dlg.setStandardButtons(QMessageBox.Ok)
|
|
2126
|
+
dlg.exec_()
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def _update_save_action(self):
|
|
2130
|
+
widget = self.tab_widget.currentWidget()
|
|
2131
|
+
saveable = isinstance(widget, (PlotTab, CalibrationTab))
|
|
2132
|
+
self.save_action.setEnabled(saveable)
|
|
2133
|
+
self.save_as_action.setEnabled(saveable)
|
|
2134
|
+
if isinstance(widget, CalibrationTab) and widget._project_path:
|
|
2135
|
+
self.statusBar.showMessage(widget._project_path)
|
|
2136
|
+
elif isinstance(widget, PlotTab) and hasattr(widget, "file_path") and widget.file_path:
|
|
2137
|
+
self.statusBar.showMessage(widget.file_path)
|
|
2138
|
+
elif isinstance(widget, LivePlotTab):
|
|
2139
|
+
self.statusBar.showMessage(f"Live: {widget._port}")
|
|
2140
|
+
else:
|
|
2141
|
+
self.statusBar.showMessage("")
|
|
2142
|
+
|
|
2143
|
+
def save_current_tab(self):
|
|
2144
|
+
"""Save to existing path (no dialog if path is known)."""
|
|
2145
|
+
widget = self.tab_widget.currentWidget()
|
|
2146
|
+
if isinstance(widget, CalibrationTab):
|
|
2147
|
+
widget.save_project()
|
|
2148
|
+
elif isinstance(widget, PlotTab):
|
|
2149
|
+
widget.save_as()
|
|
2150
|
+
|
|
2151
|
+
def save_current_tab_as(self):
|
|
2152
|
+
"""Always open Save As dialog."""
|
|
2153
|
+
widget = self.tab_widget.currentWidget()
|
|
2154
|
+
if isinstance(widget, CalibrationTab):
|
|
2155
|
+
widget.save_project_as()
|
|
2156
|
+
elif isinstance(widget, PlotTab):
|
|
2157
|
+
widget.save_as()
|
|
2158
|
+
|
|
2159
|
+
def open_new_file(self, flag):
|
|
2160
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
2161
|
+
self, "Open file", "",
|
|
2162
|
+
"All supported files (*.dosview_calib *.TXT *.txt *.npz);;Calibration (*.dosview_calib);;Log files (*.TXT *.txt);;Saved data (*.npz);;All files (*)"
|
|
2163
|
+
)
|
|
2164
|
+
if not path:
|
|
2165
|
+
return
|
|
2166
|
+
if path.endswith(".dosview_calib"):
|
|
2167
|
+
self.openCalibrationTab(preload_path=path)
|
|
2168
|
+
else:
|
|
2169
|
+
self.openPlotTab(path)
|
|
2170
|
+
|
|
2171
|
+
def closeEvent(self, event):
|
|
2172
|
+
print("Closing dosview...")
|
|
2173
|
+
self.settings.setValue("geometry", self.saveGeometry())
|
|
2174
|
+
self.settings.setValue("windowState", self.saveState())
|
|
2175
|
+
event.accept()
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def main():
|
|
2179
|
+
parser = argparse.ArgumentParser(description='Process some integers.')
|
|
2180
|
+
parser.add_argument('file_path', type=str, help='Path to the input file', default=False, nargs='?')
|
|
2181
|
+
parser.add_argument('--airdos', action='store_true', help='Enable airdos control tab')
|
|
2182
|
+
parser.add_argument('--labdos', action='store_true', help='Enable labdos control tab')
|
|
2183
|
+
parser.add_argument('--calibration', action='store_true', help='Enable calibration tab')
|
|
2184
|
+
parser.add_argument('--no_gui', action='store_true', help='Disable GUI and run in headless mode')
|
|
2185
|
+
parser.add_argument('--version', action='store_true', help='Print version and exit')
|
|
2186
|
+
parser.add_argument('--new-window', action='store_true', help="Open file in new window")
|
|
2187
|
+
|
|
2188
|
+
args = parser.parse_args()
|
|
2189
|
+
|
|
2190
|
+
if args.version:
|
|
2191
|
+
print(f"dosview version {__version__}")
|
|
2192
|
+
sys.exit(0)
|
|
2193
|
+
|
|
2194
|
+
print(args)
|
|
2195
|
+
|
|
2196
|
+
pg.setConfigOption('background', 'w')
|
|
2197
|
+
pg.setConfigOption('foreground', 'gray')
|
|
2198
|
+
|
|
2199
|
+
app = QApplication(sys.argv)
|
|
2200
|
+
|
|
2201
|
+
# Create a local server for IPC
|
|
2202
|
+
server_name = 'dosview'
|
|
2203
|
+
socket = QLocalSocket()
|
|
2204
|
+
socket.connectToServer(server_name)
|
|
2205
|
+
|
|
2206
|
+
if socket.waitForConnected(500):
|
|
2207
|
+
socket.write(args.file_path.encode())
|
|
2208
|
+
socket.flush()
|
|
2209
|
+
socket.waitForBytesWritten(1000)
|
|
2210
|
+
socket.disconnectFromServer()
|
|
2211
|
+
print("dosview is already running. Sending file path to the running instance.")
|
|
2212
|
+
sys.exit(0)
|
|
2213
|
+
else:
|
|
2214
|
+
server = QLocalServer()
|
|
2215
|
+
server.listen(server_name)
|
|
2216
|
+
|
|
2217
|
+
def handle_connection():
|
|
2218
|
+
socket = server.nextPendingConnection()
|
|
2219
|
+
if socket.waitForReadyRead(1000):
|
|
2220
|
+
filename = socket.readAll().data().decode()
|
|
2221
|
+
print("Opening file from external instance startup ...", filename)
|
|
2222
|
+
ex.openPlotTab(filename)
|
|
2223
|
+
ex.activateWindow()
|
|
2224
|
+
ex.raise_()
|
|
2225
|
+
ex.setFocus()
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
server.newConnection.connect(handle_connection)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
ex = App(args)
|
|
2233
|
+
sys.exit(app.exec_())
|
|
2234
|
+
|
|
2235
|
+
if __name__ == '__main__':
|
|
2236
|
+
main()
|