diff --git a/.gitignore b/.gitignore index 9f11b75..2483976 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/ +__pycache__/ diff --git a/gnss_sky_plot.py b/gnss_sky_plot.py index 8f7912d..b8bd129 100644 --- a/gnss_sky_plot.py +++ b/gnss_sky_plot.py @@ -2,14 +2,15 @@ """ GNSS Sky Plot Viewer Unterstützt UBX über serielle Schnittstelle UND TCP/IP (Ser2net). +TCP/IP-Verbindungen werden in einer persistenten LRU-Liste (max. 5) gespeichert. """ import sys import struct import socket +import math import serial import serial.tools.list_ports -import math from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, @@ -17,12 +18,13 @@ from PyQt5.QtWidgets import ( QHeaderView, QSplitter, QTabWidget, QLineEdit, QSpinBox, QFormLayout, QGroupBox, ) -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings from PyQt5.QtGui import ( QPainter, QPen, QBrush, QColor, QFont, QRadialGradient, ) - +from ubx_parser import UbxPacketizer +ubx_packetizer = UbxPacketizer() # --------------------------------------------------------------------------- # UBX Protokoll-Konstanten @@ -66,9 +68,9 @@ def ubx_checksum(payload: bytes) -> tuple[int, int]: def build_ubx(cls: int, msg_id: int, payload: bytes = b"") -> bytes: - header = bytes([UBX_SYNC1, UBX_SYNC2, cls, msg_id]) - length = struct.pack(" list[dict]: if off + 12 > len(payload): break gnss_id, sv_id, cno, elev, azim, _, flags = struct.unpack_from( - " list[dict]: # --------------------------------------------------------------------------- -# Abstrakte Transport-Basisklasse +# Transport-Abstraktionen # --------------------------------------------------------------------------- class GnssTransport: - """Gemeinsame Lese-/Schreibschnittstelle für seriell und TCP.""" - def read(self, n: int) -> bytes: raise NotImplementedError @@ -183,7 +183,7 @@ class TcpTransport(GnssTransport): self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock.settimeout(timeout) self._sock.connect((host, port)) - self._sock.settimeout(1.0) # blockierendes Lesen mit 1 s Timeout + self._sock.settimeout(1.0) def read(self, n: int) -> bytes: try: @@ -203,7 +203,7 @@ class TcpTransport(GnssTransport): # --------------------------------------------------------------------------- -# Einheitlicher GNSS-Lese-Thread +# GNSS Lese-Thread # --------------------------------------------------------------------------- class GnssReader(QThread): satellites_updated = pyqtSignal(list) @@ -211,13 +211,9 @@ class GnssReader(QThread): connected = pyqtSignal(str) def __init__(self, transport_factory): - """ - transport_factory: callable ohne Argumente, das ein GnssTransport - zurückgibt oder eine Exception wirft. - """ super().__init__() - self._factory = transport_factory - self._running = False + self._factory = transport_factory + self._running = False self._transport = None def run(self): @@ -231,14 +227,10 @@ class GnssReader(QThread): while self._running: chunk = self._transport.read(512) - if chunk: - buf += chunk - if len(buf) > 8192: - buf = buf[-1024:] - frames = parse_ubx_frames(buf) - sats = extract_satellites(frames) - if sats: - self.satellites_updated.emit(sats) + frames = ubx_packetizer.on_recv(chunk) + sats = extract_satellites(frames) + if sats: + self.satellites_updated.emit(sats) else: # Kein Datum – erneut pollen self._transport.write(poll_msg) @@ -255,6 +247,38 @@ class GnssReader(QThread): self.wait(3000) +# --------------------------------------------------------------------------- +# LRU-Speicher (persistent via QSettings) +# --------------------------------------------------------------------------- +class LruStore: + """Speichert bis zu max_size TCP-Verbindungen persistent (host:port).""" + + def __init__(self, max_size: int = 5): + self._max = max_size + self._cfg = QSettings("gnss_skyplot", "connections") + + def entries(self) -> list[str]: + val = self._cfg.value("tcp_lru", []) + if isinstance(val, str): + val = [val] + return val or [] + + def add(self, host: str, port: int) -> None: + entry = f"{host}:{port}" + entries = [e for e in self.entries() if e != entry] + entries.insert(0, entry) + self._cfg.setValue("tcp_lru", entries[: self._max]) + self._cfg.sync() + + def clear(self) -> None: + self._cfg.remove("tcp_lru") + self._cfg.sync() + + def parse(self, entry: str) -> tuple[str, int]: + host, port_str = entry.rsplit(":", 1) + return host.strip(), int(port_str.strip()) + + # --------------------------------------------------------------------------- # Sky-Plot Widget # --------------------------------------------------------------------------- @@ -288,19 +312,21 @@ class SkyPlotWidget(QWidget): painter.setPen(Qt.NoPen) painter.drawEllipse( int(cx - radius), int(cy - radius), - int(radius * 2), int(radius * 2), + int(radius * 2), int(radius * 2), ) def _draw_grid(self, painter, cx, cy, radius): pen = QPen(QColor("#2a4a6a"), 1, Qt.DashLine) painter.setPen(pen) painter.setFont(QFont("Monospace", 8)) + for elev in (0, 30, 60): r = radius * (1 - elev / 90) painter.drawEllipse(int(cx - r), int(cy - r), int(r * 2), int(r * 2)) painter.setPen(QColor("#4a7a9b")) painter.drawText(int(cx + 4), int(cy - r), f"{elev}°") painter.setPen(pen) + for azim in range(0, 360, 45): rad = math.radians(azim) painter.drawLine( @@ -309,6 +335,7 @@ class SkyPlotWidget(QWidget): int(cx + math.sin(rad) * radius), int(cy - math.cos(rad) * radius), ) + painter.setPen(QColor("#7ab7d4")) painter.setFont(QFont("Monospace", 10, QFont.Bold)) for label, azim in (("N", 0), ("O", 90), ("S", 180), ("W", 270)): @@ -329,10 +356,10 @@ class SkyPlotWidget(QWidget): for sat in self._satellites: if sat["elev"] < 0: continue - color = GNSS_COLORS.get(sat["gnssId"], QColor("#ffffff")) - x, y = self._az_el_to_xy(sat["azim"], sat["elev"], cx, cy, radius) - cno = max(0, min(50, sat["cno"])) - dot_r = 4 + int(cno / 50 * 8) + color = GNSS_COLORS.get(sat["gnssId"], QColor("#ffffff")) + x, y = self._az_el_to_xy(sat["azim"], sat["elev"], cx, cy, radius) + cno = max(0, min(50, sat["cno"])) + dot_r = 4 + int(cno / 50 * 8) glow = QColor(color) glow.setAlpha(60) @@ -342,6 +369,7 @@ class SkyPlotWidget(QWidget): int(x - dot_r * 1.8), int(y - dot_r * 1.8), int(dot_r * 3.6), int(dot_r * 3.6), ) + if sat["used"]: painter.setBrush(QBrush(color)) painter.setPen(QPen(QColor("#ffffff"), 1)) @@ -350,7 +378,10 @@ class SkyPlotWidget(QWidget): faded.setAlpha(120) painter.setBrush(QBrush(faded)) painter.setPen(QPen(color, 1, Qt.DashLine)) - painter.drawEllipse(int(x - dot_r), int(y - dot_r), dot_r * 2, dot_r * 2) + + painter.drawEllipse( + int(x - dot_r), int(y - dot_r), dot_r * 2, dot_r * 2 + ) painter.setPen(QColor("#ffffff")) painter.setFont(QFont("Monospace", 7)) painter.drawText(int(x + dot_r + 2), int(y + 4), str(sat["svId"])) @@ -413,7 +444,7 @@ class SatelliteTable(QTableWidget): # --------------------------------------------------------------------------- -# Verbindungs-Panel (Tabs: Seriell | TCP/IP) +# Stil-Konstante # --------------------------------------------------------------------------- FIELD_STYLE = """ QLineEdit, QComboBox, QSpinBox { @@ -423,12 +454,19 @@ FIELD_STYLE = """ padding: 3px 6px; border-radius: 3px; } + QComboBox QAbstractItemView { + background-color: #0d1b2a; + color: #cccccc; + selection-background-color: #1a3a5c; + } """ -class ConnectionPanel(QGroupBox): - """Gibt über connect_requested ein transport_factory-Callable zurück.""" - connect_requested = pyqtSignal(object, str) # (factory, label) +# --------------------------------------------------------------------------- +# Verbindungs-Panel +# --------------------------------------------------------------------------- +class ConnectionPanel(QGroupBox): + connect_requested = pyqtSignal(object, str) disconnect_requested = pyqtSignal() def __init__(self, parent=None): @@ -438,12 +476,12 @@ class ConnectionPanel(QGroupBox): border-radius: 4px; margin-top: 8px; padding: 6px; } QGroupBox::title { subcontrol-origin: margin; left: 8px; } """ + FIELD_STYLE) + self._lru = LruStore(max_size=5) self._build_ui() def _build_ui(self): root = QVBoxLayout(self) - # Tabs self._tabs = QTabWidget() self._tabs.setStyleSheet(""" QTabBar::tab { background: #1a3a5c; color: #cccccc; @@ -451,7 +489,7 @@ class ConnectionPanel(QGroupBox): QTabBar::tab:selected { background: #2a5a8c; color: #ffffff; } """) - # --- Tab: Seriell --- + # ── Tab: Seriell ───────────────────────────────────────────────────── serial_widget = QWidget() serial_form = QFormLayout(serial_widget) serial_form.setContentsMargins(6, 6, 6, 6) @@ -474,18 +512,47 @@ class ConnectionPanel(QGroupBox): self._tabs.addTab(serial_widget, "🔌 Seriell") - # --- Tab: TCP/IP --- + # ── Tab: TCP/IP ─────────────────────────────────────────────────────── tcp_widget = QWidget() - tcp_form = QFormLayout(tcp_widget) - tcp_form.setContentsMargins(6, 6, 6, 6) + tcp_layout = QVBoxLayout(tcp_widget) + tcp_layout.setContentsMargins(6, 6, 6, 6) + tcp_layout.setSpacing(6) - self._tcp_host = QLineEdit("192.168.1.100") + # LRU-Gruppe + lru_group = QGroupBox("Letzte Verbindungen") + lru_group.setStyleSheet(""" + QGroupBox { color: #7ab7d4; border: 1px solid #2a4a6a; + border-radius: 3px; margin-top: 6px; padding: 4px; } + QGroupBox::title { subcontrol-origin: margin; left: 6px; } + """ + FIELD_STYLE) + lru_layout = QHBoxLayout(lru_group) + lru_layout.setContentsMargins(4, 10, 4, 4) + + self._lru_combo = QComboBox() + self._lru_combo.setMinimumWidth(220) + self._lru_combo.setPlaceholderText("– Frühere Verbindung wählen –") + self._lru_combo.currentIndexChanged.connect(self._on_lru_selected) + lru_layout.addWidget(self._lru_combo, stretch=1) + + clear_btn = QPushButton("🗑") + clear_btn.setFixedWidth(28) + clear_btn.setToolTip("Liste leeren") + clear_btn.clicked.connect(self._clear_lru) + lru_layout.addWidget(clear_btn) + + tcp_layout.addWidget(lru_group) + + # Manuelle Eingabe + tcp_form = QFormLayout() + tcp_form.setSpacing(6) + + self._tcp_host = QLineEdit() self._tcp_host.setPlaceholderText("IP-Adresse oder Hostname") tcp_form.addRow("Host:", self._tcp_host) self._tcp_port = QSpinBox() self._tcp_port.setRange(1, 65535) - self._tcp_port.setValue(2947) # gpsd-Standard; Ser2net oft 4001 + self._tcp_port.setValue(4001) tcp_form.addRow("Port:", self._tcp_port) self._tcp_timeout = QSpinBox() @@ -494,6 +561,9 @@ class ConnectionPanel(QGroupBox): self._tcp_timeout.setSuffix(" s") tcp_form.addRow("Timeout:", self._tcp_timeout) + tcp_layout.addLayout(tcp_form) + tcp_layout.addStretch() + self._tabs.addTab(tcp_widget, "🌐 TCP/IP") root.addWidget(self._tabs) @@ -507,6 +577,36 @@ class ConnectionPanel(QGroupBox): root.addLayout(btn_row) self._refresh_ports() + self._refresh_lru_combo() + + # ── LRU ────────────────────────────────────────────────────────────────── + + def _refresh_lru_combo(self): + self._lru_combo.blockSignals(True) + self._lru_combo.clear() + for entry in self._lru.entries(): + self._lru_combo.addItem(entry) + self._lru_combo.setCurrentIndex(-1) + self._lru_combo.blockSignals(False) + + def _on_lru_selected(self, index: int): + if index < 0: + return + entry = self._lru_combo.currentText() + if not entry: + return + try: + host, port = self._lru.parse(entry) + self._tcp_host.setText(host) + self._tcp_port.setValue(port) + except (ValueError, IndexError): + pass + + def _clear_lru(self): + self._lru.clear() + self._refresh_lru_combo() + + # ── Seriell ─────────────────────────────────────────────────────────────── def _refresh_ports(self): self._port_combo.clear() @@ -515,13 +615,14 @@ class ConnectionPanel(QGroupBox): if self._port_combo.count() == 0: self._port_combo.addItem("Kein Port gefunden", None) + # ── Verbinden ───────────────────────────────────────────────────────────── + def _on_connect_clicked(self): if self._connect_btn.text().startswith("■"): self.disconnect_requested.emit() return if self._tabs.currentIndex() == 0: - # Seriell port = self._port_combo.currentData() baud = int(self._baud_combo.currentText()) if not port: @@ -529,12 +630,13 @@ class ConnectionPanel(QGroupBox): factory = lambda p=port, b=baud: SerialTransport(p, b) label = f"{port} @ {baud} Baud" else: - # TCP/IP host = self._tcp_host.text().strip() port = self._tcp_port.value() timeout = self._tcp_timeout.value() if not host: return + self._lru.add(host, port) + self._refresh_lru_combo() factory = lambda h=host, p=port, t=timeout: TcpTransport(h, p, t) label = f"{host}:{port}" @@ -563,7 +665,7 @@ class MainWindow(QMainWindow): root = QVBoxLayout(central) root.setContentsMargins(8, 8, 8, 8) - # --- Obere Leiste --- + # Obere Leiste top = QHBoxLayout() self._conn_panel = ConnectionPanel() @@ -579,7 +681,7 @@ class MainWindow(QMainWindow): root.addLayout(top) - # --- Splitter: Sky Plot | Tabelle --- + # Splitter: Sky Plot | Tabelle splitter = QSplitter(Qt.Horizontal) self._sky_plot = SkyPlotWidget() self._table = SatelliteTable() diff --git a/ubx_parser.py b/ubx_parser.py new file mode 100644 index 0000000..bae97d0 --- /dev/null +++ b/ubx_parser.py @@ -0,0 +1,92 @@ +import struct + +UBX_SYNC_WORD = b'\xb5\x62' + +def checksum(data: bytes) -> bytes: + ck_a = 0 + ck_b = 0 + for i in range(len(data)): + ck_a = (ck_a + data[i]) & 0xFF + ck_b = (ck_b + ck_a) & 0xFF + + return struct.pack("BB", ck_a, ck_b) + +class Hdr: + def __init__(self, _class: int, _id: int, _length: int): + self.mclass = _class + self.mid = _id + self.length = _length + + +def frame_parse_hdr(frame: bytes) -> Hdr|None: + hdr = Hdr(0,0,0) + + if len(frame) < 4: + return None + + hdr.mclass = struct.unpack('B', frame[0:1])[0] + hdr.mid = struct.unpack('B', frame[1:2])[0] + hdr.length = struct.unpack(' 0: + self.packet += self.d_save + self.d_save = b'' + self.sync_count = 0 + if not self.wait_sync: + self.packet += struct.pack('B', d) + data, hdr = frame_parse(self.packet) + if data is not None: + if hdr.length == len(data): + frames.append({"cls": hdr.mclass, "id": hdr.mid, "payload": data}) + self.packet = b'' + self.wait_sync = True + return frames \ No newline at end of file