_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
361 lines
14 KiB
Python
361 lines
14 KiB
Python
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()
|