fixed parsing
This commit is contained in:
@@ -1 +1,2 @@
|
||||
.idea/
|
||||
__pycache__/
|
||||
|
||||
+136
-34
@@ -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
|
||||
@@ -110,7 +112,7 @@ def parse_nav_sat(payload: bytes) -> list[dict]:
|
||||
if off + 12 > len(payload):
|
||||
break
|
||||
gnss_id, sv_id, cno, elev, azim, _, flags = struct.unpack_from(
|
||||
"<BBBbhiI", payload, off
|
||||
"<BBBBhhI", payload[off:off + 12]
|
||||
)
|
||||
sats.append({
|
||||
"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:
|
||||
"""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,10 +211,6 @@ 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
|
||||
@@ -231,11 +227,7 @@ 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)
|
||||
frames = ubx_packetizer.on_recv(chunk)
|
||||
sats = extract_satellites(frames)
|
||||
if sats:
|
||||
self.satellites_updated.emit(sats)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -295,12 +319,14 @@ class SkyPlotWidget(QWidget):
|
||||
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)):
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user