gui: add File source type to connection tab

- ConnectionConfig: new file_path and delay fields
- ConnectionRow: File page with path input, Browse button, delay
  spinner (0–10 s), and auto-fill of receiver-ID from filename
- ReceiverManager.connect_receiver: FileBackend wired for 'file' type;
  UBX poll timer skipped for file sources
- FileBackend.register_xcvr: fallback to first unoccupied entry when
  name does not match filename pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:47:28 +02:00
co-authored by Claude Sonnet 4.6
parent 4c00f57a85
commit ff686b64f7
3 changed files with 80 additions and 20 deletions
+6 -1
View File
@@ -35,7 +35,12 @@ class FileBackend(ABackend):
e['xcvr'] = xcvr e['xcvr'] = xcvr
xcvr.on_register(self) xcvr.on_register(self)
return return
raise Exception(f"No log file found for source \"{xcvr.name}\"") for e in self._entries:
if e['xcvr'] is None:
e['xcvr'] = xcvr
xcvr.on_register(self)
return
raise Exception(f"No available log file entry for \"{xcvr.name}\"")
def connect(self): def connect(self):
for e in self._entries: for e in self._entries:
+54 -6
View File
@@ -8,12 +8,13 @@ for _p in [_root, _gui]:
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel,
QLineEdit, QComboBox, QSpinBox, QStackedWidget, QScrollArea, QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QStackedWidget,
QFrame, QMessageBox, QSizePolicy, QScrollArea, QFrame, QMessageBox, QSizePolicy, QFileDialog,
) )
from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtCore import pyqtSignal, Qt
from data_model import ReceiverManager, ConnectionConfig from data_model import ReceiverManager, ConnectionConfig
from backend.file_backend import source_id_from_path
BAUD_RATES = ['4800', '9600', '19200', '38400', '57600', '115200', BAUD_RATES = ['4800', '9600', '19200', '38400', '57600', '115200',
'230400', '460800', '921600'] '230400', '460800', '921600']
@@ -53,7 +54,7 @@ class ConnectionRow(QWidget):
# ── Connection type ────────────────────────────────────────────────── # ── Connection type ──────────────────────────────────────────────────
self._type_combo = QComboBox() self._type_combo = QComboBox()
self._type_combo.addItems(['TCP', 'Serial']) self._type_combo.addItems(['TCP', 'Serial', 'File'])
self._type_combo.currentIndexChanged.connect(self._on_type_changed) self._type_combo.currentIndexChanged.connect(self._on_type_changed)
layout.addWidget(self._type_combo) layout.addWidget(self._type_combo)
@@ -95,6 +96,32 @@ class ConnectionRow(QWidget):
ser_l.addWidget(self._baud_combo) ser_l.addWidget(self._baud_combo)
self._stack.addWidget(ser_w) 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, 10.0)
self._delay_spin.setSingleStep(0.1)
self._delay_spin.setDecimals(1)
self._delay_spin.setValue(1.0)
self._delay_spin.setSuffix(" s")
self._delay_spin.setFixedWidth(72)
file_l.addWidget(self._delay_spin)
self._stack.addWidget(file_w)
layout.addWidget(self._stack) layout.addWidget(self._stack)
layout.addStretch() layout.addStretch()
@@ -115,6 +142,17 @@ class ConnectionRow(QWidget):
if config: if config:
self.apply_config(config) self.apply_config(config)
# ── 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 ──────────────────────────────────────────────── # ── Serial port detection ────────────────────────────────────────────────
def _populate_serial_ports(self): def _populate_serial_ports(self):
@@ -129,7 +167,8 @@ class ConnectionRow(QWidget):
def apply_config(self, config: ConnectionConfig): def apply_config(self, config: ConnectionConfig):
self._id_edit.setText(config.receiver_id) self._id_edit.setText(config.receiver_id)
self._type_combo.setCurrentIndex(0 if config.conn_type == 'tcp' else 1) self._type_combo.setCurrentIndex(
{'tcp': 0, 'serial': 1, 'file': 2}.get(config.conn_type, 0))
self._host_edit.setText(config.host) self._host_edit.setText(config.host)
self._port_spin.setValue(config.port) self._port_spin.setValue(config.port)
if config.serial_port: if config.serial_port:
@@ -137,9 +176,11 @@ class ConnectionRow(QWidget):
self._ser_combo.setCurrentIndex(idx) if idx >= 0 \ self._ser_combo.setCurrentIndex(idx) if idx >= 0 \
else self._ser_combo.setCurrentText(config.serial_port) else self._ser_combo.setCurrentText(config.serial_port)
self._baud_combo.setCurrentText(str(config.baud_rate)) 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: def get_config(self) -> ConnectionConfig:
t = 'tcp' if self._type_combo.currentIndex() == 0 else 'serial' t = ['tcp', 'serial', 'file'][self._type_combo.currentIndex()]
return ConnectionConfig( return ConnectionConfig(
receiver_id = self._id_edit.text().strip(), receiver_id = self._id_edit.text().strip(),
conn_type = t, conn_type = t,
@@ -147,6 +188,8 @@ class ConnectionRow(QWidget):
port = self._port_spin.value(), port = self._port_spin.value(),
serial_port = self._ser_combo.currentText().strip(), serial_port = self._ser_combo.currentText().strip(),
baud_rate = int(self._baud_combo.currentText()), baud_rate = int(self._baud_combo.currentText()),
file_path = self._file_edit.text().strip(),
delay = self._delay_spin.value(),
) )
def get_receiver_id(self) -> str: def get_receiver_id(self) -> str:
@@ -157,7 +200,8 @@ class ConnectionRow(QWidget):
self._dot.set_connected(connected) self._dot.set_connected(connected)
self._conn_btn.setText("Disconnect" if connected else "Connect") self._conn_btn.setText("Disconnect" if connected else "Connect")
for w in [self._id_edit, self._type_combo, self._host_edit, for w in [self._id_edit, self._type_combo, self._host_edit,
self._port_spin, self._ser_combo, self._baud_combo]: self._port_spin, self._ser_combo, self._baud_combo,
self._file_edit, self._delay_spin]:
w.setEnabled(not connected) w.setEnabled(not connected)
# ── Slots ──────────────────────────────────────────────────────────────── # ── Slots ────────────────────────────────────────────────────────────────
@@ -180,6 +224,10 @@ class ConnectionRow(QWidget):
QMessageBox.warning(self, "GNSS Monitor", QMessageBox.warning(self, "GNSS Monitor",
f"ID '{config.receiver_id}' wird bereits verwendet.") f"ID '{config.receiver_id}' wird bereits verwendet.")
return 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._model.add_receiver(config)
self._rid = config.receiver_id self._rid = config.receiver_id
+20 -13
View File
@@ -52,16 +52,20 @@ class SatelliteData:
@dataclass @dataclass
class ConnectionConfig: class ConnectionConfig:
receiver_id: str receiver_id: str
conn_type: str = 'tcp' conn_type: str = 'tcp'
host: str = '' host: str = ''
port: int = 8721 port: int = 8721
serial_port: str = '' serial_port: str = ''
baud_rate: int = 115200 baud_rate: int = 115200
file_path: str = ''
delay: float = 1.0
def display_str(self) -> str: def display_str(self) -> str:
if self.conn_type == 'tcp': if self.conn_type == 'tcp':
return f"{self.receiver_id} [{self.host}:{self.port}]" return f"{self.receiver_id} [{self.host}:{self.port}]"
return f"{self.receiver_id} [{self.serial_port} @ {self.baud_rate}]" if self.conn_type == 'serial':
return f"{self.receiver_id} [{self.serial_port} @ {self.baud_rate}]"
return f"{self.receiver_id} [file: {os.path.basename(self.file_path)}]"
def to_dict(self): def to_dict(self):
return dataclasses.asdict(self) return dataclasses.asdict(self)
@@ -148,6 +152,7 @@ class ReceiverManager(QObject):
def connect_receiver(self, rid: str): def connect_receiver(self, rid: str):
from listeners import NavSatQtListener, GsaQtListener from listeners import NavSatQtListener, GsaQtListener
from backend.serial_backend import SerialBackend from backend.serial_backend import SerialBackend
from backend.file_backend import FileBackend
config = self._configs.get(rid) config = self._configs.get(rid)
state = self._states.get(rid) state = self._states.get(rid)
@@ -160,8 +165,10 @@ class ReceiverManager(QObject):
backend = NetworkBackend( backend = NetworkBackend(
[{'name': rid, 'host': config.host, 'port': config.port}] [{'name': rid, 'host': config.host, 'port': config.port}]
) )
else: elif config.conn_type == 'serial':
backend = SerialBackend(config.serial_port, config.baud_rate) backend = SerialBackend(config.serial_port, config.baud_rate)
else:
backend = FileBackend([config.file_path], delay=config.delay)
backend.register_xcvr(xcvr) backend.register_xcvr(xcvr)
@@ -184,12 +191,12 @@ class ReceiverManager(QObject):
state.transceiver = xcvr state.transceiver = xcvr
state.connected = True state.connected = True
# Poll UBX messages every second (receiver only sends on request by default) if config.conn_type != 'file':
timer = QTimer(self) timer = QTimer(self)
timer.setInterval(1000) timer.setInterval(1000)
timer.timeout.connect(lambda r=rid: self._poll_ubx(r)) timer.timeout.connect(lambda r=rid: self._poll_ubx(r))
timer.start() timer.start()
state.poll_timer = timer state.poll_timer = timer
self._push_lru(config) self._push_lru(config)
self.connection_changed.emit(rid, True) self.connection_changed.emit(rid, True)