fixed parsing

This commit is contained in:
2026-05-16 22:02:17 +02:00
parent 6b37db6c51
commit ffee30e0d9
3 changed files with 242 additions and 47 deletions
+1
View File
@@ -1 +1,2 @@
.idea/ .idea/
__pycache__/
+149 -47
View File
@@ -2,14 +2,15 @@
""" """
GNSS Sky Plot Viewer GNSS Sky Plot Viewer
Unterstützt UBX über serielle Schnittstelle UND TCP/IP (Ser2net). 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 sys
import struct import struct
import socket import socket
import math
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
import math
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QComboBox, QPushButton, QHBoxLayout, QLabel, QComboBox, QPushButton,
@@ -17,12 +18,13 @@ from PyQt5.QtWidgets import (
QHeaderView, QSplitter, QTabWidget, QLineEdit, QHeaderView, QSplitter, QTabWidget, QLineEdit,
QSpinBox, QFormLayout, QGroupBox, QSpinBox, QFormLayout, QGroupBox,
) )
from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
from PyQt5.QtGui import ( from PyQt5.QtGui import (
QPainter, QPen, QBrush, QColor, QFont, QPainter, QPen, QBrush, QColor, QFont,
QRadialGradient, QRadialGradient,
) )
from ubx_parser import UbxPacketizer
ubx_packetizer = UbxPacketizer()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# UBX Protokoll-Konstanten # 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: def build_ubx(cls: int, msg_id: int, payload: bytes = b"") -> bytes:
header = bytes([UBX_SYNC1, UBX_SYNC2, cls, msg_id]) header = bytes([UBX_SYNC1, UBX_SYNC2, cls, msg_id])
length = struct.pack("<H", len(payload)) length = struct.pack("<H", len(payload))
raw = bytes([cls, msg_id]) + length + payload raw = bytes([cls, msg_id]) + length + payload
ck_a, ck_b = ubx_checksum(raw) ck_a, ck_b = ubx_checksum(raw)
return header + length + payload + bytes([ck_a, ck_b]) return header + length + payload + bytes([ck_a, ck_b])
@@ -110,7 +112,7 @@ def parse_nav_sat(payload: bytes) -> list[dict]:
if off + 12 > len(payload): if off + 12 > len(payload):
break break
gnss_id, sv_id, cno, elev, azim, _, flags = struct.unpack_from( gnss_id, sv_id, cno, elev, azim, _, flags = struct.unpack_from(
"<BBBbhiI", payload, off "<BBBBhhI", payload[off:off + 12]
) )
sats.append({ sats.append({
"gnssId": gnss_id, "svId": sv_id, "cno": cno, "gnssId": gnss_id, "svId": sv_id, "cno": cno,
@@ -148,11 +150,9 @@ def extract_satellites(frames: list[dict]) -> list[dict]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Abstrakte Transport-Basisklasse # Transport-Abstraktionen
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GnssTransport: class GnssTransport:
"""Gemeinsame Lese-/Schreibschnittstelle für seriell und TCP."""
def read(self, n: int) -> bytes: def read(self, n: int) -> bytes:
raise NotImplementedError raise NotImplementedError
@@ -183,7 +183,7 @@ class TcpTransport(GnssTransport):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(timeout) self._sock.settimeout(timeout)
self._sock.connect((host, port)) 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: def read(self, n: int) -> bytes:
try: try:
@@ -203,7 +203,7 @@ class TcpTransport(GnssTransport):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Einheitlicher GNSS-Lese-Thread # GNSS Lese-Thread
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GnssReader(QThread): class GnssReader(QThread):
satellites_updated = pyqtSignal(list) satellites_updated = pyqtSignal(list)
@@ -211,13 +211,9 @@ class GnssReader(QThread):
connected = pyqtSignal(str) connected = pyqtSignal(str)
def __init__(self, transport_factory): def __init__(self, transport_factory):
"""
transport_factory: callable ohne Argumente, das ein GnssTransport
zurückgibt oder eine Exception wirft.
"""
super().__init__() super().__init__()
self._factory = transport_factory self._factory = transport_factory
self._running = False self._running = False
self._transport = None self._transport = None
def run(self): def run(self):
@@ -231,14 +227,10 @@ class GnssReader(QThread):
while self._running: while self._running:
chunk = self._transport.read(512) chunk = self._transport.read(512)
if chunk: frames = ubx_packetizer.on_recv(chunk)
buf += chunk sats = extract_satellites(frames)
if len(buf) > 8192: if sats:
buf = buf[-1024:] self.satellites_updated.emit(sats)
frames = parse_ubx_frames(buf)
sats = extract_satellites(frames)
if sats:
self.satellites_updated.emit(sats)
else: else:
# Kein Datum erneut pollen # Kein Datum erneut pollen
self._transport.write(poll_msg) self._transport.write(poll_msg)
@@ -255,6 +247,38 @@ class GnssReader(QThread):
self.wait(3000) 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 # Sky-Plot Widget
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -288,19 +312,21 @@ class SkyPlotWidget(QWidget):
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
painter.drawEllipse( painter.drawEllipse(
int(cx - radius), int(cy - radius), 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): def _draw_grid(self, painter, cx, cy, radius):
pen = QPen(QColor("#2a4a6a"), 1, Qt.DashLine) pen = QPen(QColor("#2a4a6a"), 1, Qt.DashLine)
painter.setPen(pen) painter.setPen(pen)
painter.setFont(QFont("Monospace", 8)) painter.setFont(QFont("Monospace", 8))
for elev in (0, 30, 60): for elev in (0, 30, 60):
r = radius * (1 - elev / 90) r = radius * (1 - elev / 90)
painter.drawEllipse(int(cx - r), int(cy - r), int(r * 2), int(r * 2)) painter.drawEllipse(int(cx - r), int(cy - r), int(r * 2), int(r * 2))
painter.setPen(QColor("#4a7a9b")) painter.setPen(QColor("#4a7a9b"))
painter.drawText(int(cx + 4), int(cy - r), f"{elev}°") painter.drawText(int(cx + 4), int(cy - r), f"{elev}°")
painter.setPen(pen) painter.setPen(pen)
for azim in range(0, 360, 45): for azim in range(0, 360, 45):
rad = math.radians(azim) rad = math.radians(azim)
painter.drawLine( painter.drawLine(
@@ -309,6 +335,7 @@ class SkyPlotWidget(QWidget):
int(cx + math.sin(rad) * radius), int(cx + math.sin(rad) * radius),
int(cy - math.cos(rad) * radius), int(cy - math.cos(rad) * radius),
) )
painter.setPen(QColor("#7ab7d4")) painter.setPen(QColor("#7ab7d4"))
painter.setFont(QFont("Monospace", 10, QFont.Bold)) painter.setFont(QFont("Monospace", 10, QFont.Bold))
for label, azim in (("N", 0), ("O", 90), ("S", 180), ("W", 270)): for label, azim in (("N", 0), ("O", 90), ("S", 180), ("W", 270)):
@@ -329,10 +356,10 @@ class SkyPlotWidget(QWidget):
for sat in self._satellites: for sat in self._satellites:
if sat["elev"] < 0: if sat["elev"] < 0:
continue continue
color = GNSS_COLORS.get(sat["gnssId"], QColor("#ffffff")) color = GNSS_COLORS.get(sat["gnssId"], QColor("#ffffff"))
x, y = self._az_el_to_xy(sat["azim"], sat["elev"], cx, cy, radius) x, y = self._az_el_to_xy(sat["azim"], sat["elev"], cx, cy, radius)
cno = max(0, min(50, sat["cno"])) cno = max(0, min(50, sat["cno"]))
dot_r = 4 + int(cno / 50 * 8) dot_r = 4 + int(cno / 50 * 8)
glow = QColor(color) glow = QColor(color)
glow.setAlpha(60) glow.setAlpha(60)
@@ -342,6 +369,7 @@ class SkyPlotWidget(QWidget):
int(x - dot_r * 1.8), int(y - dot_r * 1.8), int(x - dot_r * 1.8), int(y - dot_r * 1.8),
int(dot_r * 3.6), int(dot_r * 3.6), int(dot_r * 3.6), int(dot_r * 3.6),
) )
if sat["used"]: if sat["used"]:
painter.setBrush(QBrush(color)) painter.setBrush(QBrush(color))
painter.setPen(QPen(QColor("#ffffff"), 1)) painter.setPen(QPen(QColor("#ffffff"), 1))
@@ -350,7 +378,10 @@ class SkyPlotWidget(QWidget):
faded.setAlpha(120) faded.setAlpha(120)
painter.setBrush(QBrush(faded)) painter.setBrush(QBrush(faded))
painter.setPen(QPen(color, 1, Qt.DashLine)) 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.setPen(QColor("#ffffff"))
painter.setFont(QFont("Monospace", 7)) painter.setFont(QFont("Monospace", 7))
painter.drawText(int(x + dot_r + 2), int(y + 4), str(sat["svId"])) 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 = """ FIELD_STYLE = """
QLineEdit, QComboBox, QSpinBox { QLineEdit, QComboBox, QSpinBox {
@@ -423,12 +454,19 @@ FIELD_STYLE = """
padding: 3px 6px; padding: 3px 6px;
border-radius: 3px; 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() disconnect_requested = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -438,12 +476,12 @@ class ConnectionPanel(QGroupBox):
border-radius: 4px; margin-top: 8px; padding: 6px; } border-radius: 4px; margin-top: 8px; padding: 6px; }
QGroupBox::title { subcontrol-origin: margin; left: 8px; } QGroupBox::title { subcontrol-origin: margin; left: 8px; }
""" + FIELD_STYLE) """ + FIELD_STYLE)
self._lru = LruStore(max_size=5)
self._build_ui() self._build_ui()
def _build_ui(self): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
# Tabs
self._tabs = QTabWidget() self._tabs = QTabWidget()
self._tabs.setStyleSheet(""" self._tabs.setStyleSheet("""
QTabBar::tab { background: #1a3a5c; color: #cccccc; QTabBar::tab { background: #1a3a5c; color: #cccccc;
@@ -451,7 +489,7 @@ class ConnectionPanel(QGroupBox):
QTabBar::tab:selected { background: #2a5a8c; color: #ffffff; } QTabBar::tab:selected { background: #2a5a8c; color: #ffffff; }
""") """)
# --- Tab: Seriell --- # ── Tab: Seriell ─────────────────────────────────────────────────────
serial_widget = QWidget() serial_widget = QWidget()
serial_form = QFormLayout(serial_widget) serial_form = QFormLayout(serial_widget)
serial_form.setContentsMargins(6, 6, 6, 6) serial_form.setContentsMargins(6, 6, 6, 6)
@@ -474,18 +512,47 @@ class ConnectionPanel(QGroupBox):
self._tabs.addTab(serial_widget, "🔌 Seriell") self._tabs.addTab(serial_widget, "🔌 Seriell")
# --- Tab: TCP/IP --- # ── Tab: TCP/IP ───────────────────────────────────────────────────────
tcp_widget = QWidget() tcp_widget = QWidget()
tcp_form = QFormLayout(tcp_widget) tcp_layout = QVBoxLayout(tcp_widget)
tcp_form.setContentsMargins(6, 6, 6, 6) 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") self._tcp_host.setPlaceholderText("IP-Adresse oder Hostname")
tcp_form.addRow("Host:", self._tcp_host) tcp_form.addRow("Host:", self._tcp_host)
self._tcp_port = QSpinBox() self._tcp_port = QSpinBox()
self._tcp_port.setRange(1, 65535) 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) tcp_form.addRow("Port:", self._tcp_port)
self._tcp_timeout = QSpinBox() self._tcp_timeout = QSpinBox()
@@ -494,6 +561,9 @@ class ConnectionPanel(QGroupBox):
self._tcp_timeout.setSuffix(" s") self._tcp_timeout.setSuffix(" s")
tcp_form.addRow("Timeout:", self._tcp_timeout) tcp_form.addRow("Timeout:", self._tcp_timeout)
tcp_layout.addLayout(tcp_form)
tcp_layout.addStretch()
self._tabs.addTab(tcp_widget, "🌐 TCP/IP") self._tabs.addTab(tcp_widget, "🌐 TCP/IP")
root.addWidget(self._tabs) root.addWidget(self._tabs)
@@ -507,6 +577,36 @@ class ConnectionPanel(QGroupBox):
root.addLayout(btn_row) root.addLayout(btn_row)
self._refresh_ports() 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): def _refresh_ports(self):
self._port_combo.clear() self._port_combo.clear()
@@ -515,13 +615,14 @@ class ConnectionPanel(QGroupBox):
if self._port_combo.count() == 0: if self._port_combo.count() == 0:
self._port_combo.addItem("Kein Port gefunden", None) self._port_combo.addItem("Kein Port gefunden", None)
# ── Verbinden ─────────────────────────────────────────────────────────────
def _on_connect_clicked(self): def _on_connect_clicked(self):
if self._connect_btn.text().startswith(""): if self._connect_btn.text().startswith(""):
self.disconnect_requested.emit() self.disconnect_requested.emit()
return return
if self._tabs.currentIndex() == 0: if self._tabs.currentIndex() == 0:
# Seriell
port = self._port_combo.currentData() port = self._port_combo.currentData()
baud = int(self._baud_combo.currentText()) baud = int(self._baud_combo.currentText())
if not port: if not port:
@@ -529,12 +630,13 @@ class ConnectionPanel(QGroupBox):
factory = lambda p=port, b=baud: SerialTransport(p, b) factory = lambda p=port, b=baud: SerialTransport(p, b)
label = f"{port} @ {baud} Baud" label = f"{port} @ {baud} Baud"
else: else:
# TCP/IP
host = self._tcp_host.text().strip() host = self._tcp_host.text().strip()
port = self._tcp_port.value() port = self._tcp_port.value()
timeout = self._tcp_timeout.value() timeout = self._tcp_timeout.value()
if not host: if not host:
return return
self._lru.add(host, port)
self._refresh_lru_combo()
factory = lambda h=host, p=port, t=timeout: TcpTransport(h, p, t) factory = lambda h=host, p=port, t=timeout: TcpTransport(h, p, t)
label = f"{host}:{port}" label = f"{host}:{port}"
@@ -563,7 +665,7 @@ class MainWindow(QMainWindow):
root = QVBoxLayout(central) root = QVBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8) root.setContentsMargins(8, 8, 8, 8)
# --- Obere Leiste --- # Obere Leiste
top = QHBoxLayout() top = QHBoxLayout()
self._conn_panel = ConnectionPanel() self._conn_panel = ConnectionPanel()
@@ -579,7 +681,7 @@ class MainWindow(QMainWindow):
root.addLayout(top) root.addLayout(top)
# --- Splitter: Sky Plot | Tabelle --- # Splitter: Sky Plot | Tabelle
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
self._sky_plot = SkyPlotWidget() self._sky_plot = SkyPlotWidget()
self._table = SatelliteTable() self._table = SatelliteTable()
+92
View File
@@ -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('<H', frame[2:4])[0]
return hdr
def frame_parse(frame: bytes):
_data = None
_hdr = Hdr(0,0,0)
while True:
_hdr = frame_parse_hdr(frame[0:4])
if _hdr is None:
break
if (4+_hdr.length+2) != len(frame):
break
_check_soll = frame[-2:]
_check_ist = checksum(frame[0:-2])
if _check_soll != _check_ist:
break
_data = frame[4:-2]
break
return _data, _hdr
class UbxPacketizer:
def __init__(self, sync_bytes: bytes = UBX_SYNC_WORD):
self.sync_bytes = sync_bytes
self.packet = b''
self.wait_sync = True
self.sync_count = 0
self.d_save = b''
def reset(self):
self.packet = b''
self.wait_sync = True
self.sync_count = 0
def on_recv(self, msg: bytes):
frames = []
for d in msg:
if d == self.sync_bytes[self.sync_count]:
self.d_save += struct.pack('B', d)
self.sync_count += 1
if self.sync_count == len(self.sync_bytes):
self.sync_count = 0
self.d_save = b''
self.packet = b''
self.wait_sync = False
else:
if self.sync_count > 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