import sys, os, itertools _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _gui = os.path.dirname(os.path.abspath(__file__)) for _p in [_root, _gui]: if _p not in sys.path: sys.path.insert(0, _p) from PyQt5.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QStackedWidget, QScrollArea, QFrame, QMessageBox, QSizePolicy, QFileDialog, ) from PyQt5.QtCore import pyqtSignal, Qt from data_model import ReceiverManager, ConnectionConfig from backend.file_backend import source_id_from_path BAUD_RATES = ['4800', '9600', '19200', '38400', '57600', '115200', '230400', '460800', '921600'] _connection_counter = itertools.count(1) class _StatusDot(QLabel): def __init__(self): super().__init__("●") self.setFixedWidth(20) self.setAlignment(Qt.AlignCenter) self.set_connected(False) def set_connected(self, connected: bool): color = '#4CAF50' if connected else '#9E9E9E' self.setStyleSheet(f"color: {color}; font-size: 16px;") class ConnectionRow(QWidget): remove_requested = pyqtSignal(object) def __init__(self, model: ReceiverManager, config: ConnectionConfig = None): super().__init__() self._model = model self._rid: str = None self._connected = False layout = QHBoxLayout(self) layout.setContentsMargins(4, 2, 4, 2) layout.setSpacing(6) # ── Receiver ID (read-only: pre-filled with a counter, rewritten from # MON-VER once the device answers, or derived from the file path) ── layout.addWidget(QLabel("ID:")) self._id_edit = QLineEdit() self._id_edit.setReadOnly(True) self._id_edit.setFixedWidth(110) layout.addWidget(self._id_edit) # ── Connection type ────────────────────────────────────────────────── self._type_combo = QComboBox() self._type_combo.addItems(['TCP', 'Serial', 'File']) self._type_combo.currentIndexChanged.connect(self._on_type_changed) layout.addWidget(self._type_combo) # ── Stacked: TCP page / Serial page ───────────────────────────────── self._stack = QStackedWidget() # TCP page tcp_w = QWidget() tcp_l = QHBoxLayout(tcp_w) tcp_l.setContentsMargins(0, 0, 0, 0) tcp_l.setSpacing(4) tcp_l.addWidget(QLabel("Host:")) self._host_edit = QLineEdit("192.168.1.1") self._host_edit.setFixedWidth(130) tcp_l.addWidget(self._host_edit) tcp_l.addWidget(QLabel("Port:")) self._port_spin = QSpinBox() self._port_spin.setRange(1, 65535) self._port_spin.setValue(8721) self._port_spin.setFixedWidth(75) tcp_l.addWidget(self._port_spin) self._stack.addWidget(tcp_w) # Serial page ser_w = QWidget() ser_l = QHBoxLayout(ser_w) ser_l.setContentsMargins(0, 0, 0, 0) ser_l.setSpacing(4) ser_l.addWidget(QLabel("Port:")) self._ser_combo = QComboBox() self._ser_combo.setEditable(True) self._ser_combo.setMinimumWidth(130) self._populate_serial_ports() ser_l.addWidget(self._ser_combo) ser_l.addWidget(QLabel("Baud:")) self._baud_combo = QComboBox() self._baud_combo.addItems(BAUD_RATES) self._baud_combo.setCurrentText('115200') ser_l.addWidget(self._baud_combo) self._stack.addWidget(ser_w) # File page file_w = QWidget() file_l = QHBoxLayout(file_w) file_l.setContentsMargins(0, 0, 0, 0) file_l.setSpacing(4) file_l.addWidget(QLabel("Path:")) self._file_edit = QLineEdit() self._file_edit.setPlaceholderText("log file path…") self._file_edit.setMinimumWidth(200) self._file_edit.textChanged.connect(self._on_file_path_changed) file_l.addWidget(self._file_edit) browse_btn = QPushButton("Browse") browse_btn.setFixedWidth(60) browse_btn.clicked.connect(self._browse_file) file_l.addWidget(browse_btn) file_l.addWidget(QLabel("Delay:")) self._delay_spin = QDoubleSpinBox() self._delay_spin.setRange(0.0, 1.0) self._delay_spin.setSingleStep(0.01) self._delay_spin.setDecimals(2) self._delay_spin.setValue(0.0) self._delay_spin.setSuffix(" s") self._delay_spin.setFixedWidth(84) file_l.addWidget(self._delay_spin) self._stack.addWidget(file_w) layout.addWidget(self._stack) layout.addStretch() # ── Device identification (read-only, from MON-VER on connect) ──────── self._version_label = QLabel("") self._version_label.setStyleSheet("color: #888888; font-size: 11px;") layout.addWidget(self._version_label) # ── Status / buttons ───────────────────────────────────────────────── self._dot = _StatusDot() layout.addWidget(self._dot) self._conn_btn = QPushButton("Connect") self._conn_btn.setFixedWidth(95) self._conn_btn.clicked.connect(self._toggle) layout.addWidget(self._conn_btn) rm_btn = QPushButton("Remove") rm_btn.setFixedWidth(70) rm_btn.clicked.connect(lambda: self.remove_requested.emit(self)) layout.addWidget(rm_btn) if config: self.apply_config(config) else: self._id_edit.setText(f"conn-{next(_connection_counter)}") # ── File browse ────────────────────────────────────────────────────────── def _browse_file(self): path, _ = QFileDialog.getOpenFileName( self, "Select log file", "", "Log files (*.log);;All files (*)") if path: self._file_edit.setText(path) def _on_file_path_changed(self, path: str): self._id_edit.setText(source_id_from_path(path) or '') # ── Serial port detection ──────────────────────────────────────────────── def _populate_serial_ports(self): try: import serial.tools.list_ports ports = [p.device for p in serial.tools.list_ports.comports()] except Exception: ports = [] self._ser_combo.addItems(ports or ['/dev/ttyUSB0', 'COM1']) # ── Config helpers ─────────────────────────────────────────────────────── def apply_config(self, config: ConnectionConfig): self._id_edit.setText(config.receiver_id) self._type_combo.setCurrentIndex( {'tcp': 0, 'serial': 1, 'file': 2}.get(config.conn_type, 0)) self._host_edit.setText(config.host) self._port_spin.setValue(config.port) if config.serial_port: idx = self._ser_combo.findText(config.serial_port) self._ser_combo.setCurrentIndex(idx) if idx >= 0 \ else self._ser_combo.setCurrentText(config.serial_port) self._baud_combo.setCurrentText(str(config.baud_rate)) self._file_edit.setText(config.file_path) self._delay_spin.setValue(config.delay) def get_config(self) -> ConnectionConfig: t = ['tcp', 'serial', 'file'][self._type_combo.currentIndex()] return ConnectionConfig( receiver_id = self._id_edit.text().strip(), conn_type = t, host = self._host_edit.text().strip(), port = self._port_spin.value(), serial_port = self._ser_combo.currentText().strip(), baud_rate = int(self._baud_combo.currentText()), file_path = self._file_edit.text().strip(), delay = max(0.0001, self._delay_spin.value()), ) def get_receiver_id(self) -> str: return self._rid def set_connected(self, connected: bool): self._connected = connected self._dot.set_connected(connected) self._conn_btn.setText("Disconnect" if connected else "Connect") for w in [self._type_combo, self._host_edit, self._port_spin, self._ser_combo, self._baud_combo, self._file_edit, self._delay_spin]: w.setEnabled(not connected) if not connected: self._version_label.setText("") def set_version_info(self, device_id: str, sw_version: str): if device_id: self._id_edit.setText(device_id) self._version_label.setText(sw_version) # ── Slots ──────────────────────────────────────────────────────────────── def _on_type_changed(self, idx: int): self._stack.setCurrentIndex(idx) def _toggle(self): if self._connected: if self._rid: self._model.remove_receiver(self._rid) self._rid = None return config = self.get_config() if not config.receiver_id: QMessageBox.warning(self, "GNSS Monitor", "Receiver-ID darf nicht leer sein.") return if config.receiver_id in self._model.get_receiver_ids(): QMessageBox.warning(self, "GNSS Monitor", f"ID '{config.receiver_id}' wird bereits verwendet.") return if config.conn_type == 'file' and not config.file_path: QMessageBox.warning(self, "GNSS Monitor", "Bitte eine Log-Datei auswählen.") return self._model.add_receiver(config) self._rid = config.receiver_id try: self._model.connect_receiver(config.receiver_id) except Exception as exc: QMessageBox.critical(self, "Verbindungsfehler", str(exc)) self._model.remove_receiver(config.receiver_id) self._rid = None # ── Connection tab ──────────────────────────────────────────────────────────── class ConnectionTab(QWidget): def __init__(self, model: ReceiverManager): super().__init__() self._model = model self._rows: list = [] outer = QVBoxLayout(self) outer.setContentsMargins(8, 8, 8, 8) outer.setSpacing(6) # ── Scroll area with rows ───────────────────────────────────────────── self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._inner = QWidget() self._vbox = QVBoxLayout(self._inner) self._vbox.setContentsMargins(0, 0, 0, 0) self._vbox.setSpacing(2) self._vbox.addStretch() self._scroll.setWidget(self._inner) outer.addWidget(self._scroll) # ── Bottom bar: history combo (left) + Add button (right) ───────────── bottom = QHBoxLayout() bottom.setSpacing(8) self._hist_combo = QComboBox() self._hist_combo.setPlaceholderText("Select from history…") self._hist_combo.setMinimumWidth(260) self._hist_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) self._refresh_history_combo() bottom.addWidget(self._hist_combo) add_btn = QPushButton("+ Add") add_btn.setFixedWidth(90) add_btn.clicked.connect(self._on_add) bottom.addWidget(add_btn) bottom.addStretch() outer.addLayout(bottom) model.connection_changed.connect(self._on_connection_changed) model.version_update.connect(self._on_version_update) # ── History combo ───────────────────────────────────────────────────────── def _refresh_history_combo(self): self._hist_combo.blockSignals(True) current = self._hist_combo.currentData() self._hist_combo.clear() for cfg in self._model.get_lru(): self._hist_combo.addItem(cfg.display_str(), userData=cfg) # restore selection if still present if current: idx = self._hist_combo.findText(current.display_str()) if idx >= 0: self._hist_combo.setCurrentIndex(idx) self._hist_combo.blockSignals(False) # ── Row management ──────────────────────────────────────────────────────── def _on_add(self): cfg = self._hist_combo.currentData() # None when placeholder shown self._add_row(cfg) self._hist_combo.setCurrentIndex(-1) # reset to placeholder def _add_row(self, config: ConnectionConfig = None): row = ConnectionRow(self._model, config) row.remove_requested.connect(self._remove_row) self._vbox.insertWidget(self._vbox.count() - 1, row) self._rows.append(row) def _remove_row(self, row: ConnectionRow): rid = row.get_receiver_id() if rid: self._model.remove_receiver(rid) self._vbox.removeWidget(row) row.deleteLater() if row in self._rows: self._rows.remove(row) def _on_connection_changed(self, rid: str, connected: bool): for row in self._rows: if row.get_receiver_id() == rid: row.set_connected(connected) break if connected: self._refresh_history_combo() def _on_version_update(self, rid: str, device_id: str, sw_version: str): for row in self._rows: if row.get_receiver_id() == rid: row.set_version_info(device_id, sw_version) break if device_id: self._refresh_history_combo()