Files
NmeaClient/gui/connection_tab.py
T
jensandClaude Sonnet 5 2dba3a9a2f add UBX-MON-VER support and surface device identification in CLI/GUI
Add the Ver message parser and a fixed UbxQuery (was unused and had a
broken semaphore/state bug) to poll MON-VER once on connect. The CLI
prints hw/sw version and extensions; the GUI polls it per receiver,
pre-fills the read-only ID field with a connection counter and
rewrites it from the MOD= extension once the device answers, and
propagates the resolved display name to the Sky and Plot tabs so they
no longer show the stale placeholder ID.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XM4FNUjhu9x8df5Dp1hHvd
2026-07-16 20:22:25 +02:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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