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 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
+ &copy; 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()