Files
NmeaClient/gui/connection_tab.py
jensandClaude Sonnet 5 87d75aafc0 gui: fix connection history retaining stale counter-based ID
_push_lru ran right after connect, before MON-VER resolves the device
name, so history entries kept the placeholder ID forever. Dedupe LRU
entries by connection endpoint (host/port, serial port, or file path)
instead of by receiver_id, and re-push once the device name is known
so the corrected entry replaces the placeholder instead of duplicating
it.

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

361 lines
14 KiB
Python
Raw Permalink 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
if device_id:
self._refresh_history_combo()