Files
skyplot/gnss_sky_plot.py
T
2026-05-16 22:02:17 +02:00

738 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
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
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QComboBox, QPushButton,
QStatusBar, QTableWidget, QTableWidgetItem,
QHeaderView, QSplitter, QTabWidget, QLineEdit,
QSpinBox, QFormLayout, QGroupBox,
)
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
# ---------------------------------------------------------------------------
UBX_SYNC1 = 0xB5
UBX_SYNC2 = 0x62
UBX_CLASS_NAV = 0x01
UBX_ID_NAV_SAT = 0x35
UBX_ID_NAV_SVINFO = 0x30
GNSS_NAMES = {
0: "GPS",
1: "SBAS",
2: "Galileo",
3: "BeiDou",
4: "IMES",
5: "QZSS",
6: "GLONASS",
}
GNSS_COLORS = {
0: QColor("#1E90FF"),
1: QColor("#FFA500"),
2: QColor("#00C853"),
3: QColor("#E53935"),
4: QColor("#9C27B0"),
5: QColor("#00BCD4"),
6: QColor("#FF4081"),
}
# ---------------------------------------------------------------------------
# UBX Hilfsfunktionen
# ---------------------------------------------------------------------------
def ubx_checksum(payload: bytes) -> tuple[int, int]:
ck_a = ck_b = 0
for b in payload:
ck_a = (ck_a + b) & 0xFF
ck_b = (ck_b + ck_a) & 0xFF
return ck_a, ck_b
def build_ubx(cls: int, msg_id: int, payload: bytes = b"") -> bytes:
header = bytes([UBX_SYNC1, UBX_SYNC2, cls, msg_id])
length = struct.pack("<H", len(payload))
raw = bytes([cls, msg_id]) + length + payload
ck_a, ck_b = ubx_checksum(raw)
return header + length + payload + bytes([ck_a, ck_b])
def build_poll(cls: int, msg_id: int) -> bytes:
return build_ubx(cls, msg_id, b"")
def parse_ubx_frames(data: bytes) -> list[dict]:
frames, i = [], 0
while i < len(data) - 7:
if data[i] == UBX_SYNC1 and data[i + 1] == UBX_SYNC2:
if i + 6 > len(data):
break
cls = data[i + 2]
msg_id = data[i + 3]
length = struct.unpack_from("<H", data, i + 4)[0]
end = i + 6 + length + 2
if end > len(data):
break
payload = data[i + 6: i + 6 + length]
raw_chk = data[i + 2: i + 6 + length]
ck_a, ck_b = ubx_checksum(raw_chk)
if ck_a == data[end - 2] and ck_b == data[end - 1]:
frames.append({"cls": cls, "id": msg_id, "payload": payload})
i = end
else:
i += 1
return frames
def parse_nav_sat(payload: bytes) -> list[dict]:
if len(payload) < 8:
return []
num_svs = payload[5]
sats = []
for n in range(num_svs):
off = 8 + n * 12
if off + 12 > len(payload):
break
gnss_id, sv_id, cno, elev, azim, _, flags = struct.unpack_from(
"<BBBBhhI", payload[off:off + 12]
)
sats.append({
"gnssId": gnss_id, "svId": sv_id, "cno": cno,
"elev": elev, "azim": azim, "used": bool(flags & 0x08),
})
return sats
def parse_nav_svinfo(payload: bytes) -> list[dict]:
if len(payload) < 8:
return []
num_ch = payload[4]
sats = []
for n in range(num_ch):
off = 8 + n * 12
if off + 12 > len(payload):
break
_, svid, flags, _, cno, elev, azim, _ = struct.unpack_from(
"<BBBBBbhI", payload, off
)
sats.append({
"gnssId": 0, "svId": svid, "cno": cno,
"elev": elev, "azim": azim, "used": bool(flags & 0x01),
})
return sats
def extract_satellites(frames: list[dict]) -> list[dict]:
for f in frames:
if f["cls"] == UBX_CLASS_NAV and f["id"] == UBX_ID_NAV_SAT:
return parse_nav_sat(f["payload"])
if f["cls"] == UBX_CLASS_NAV and f["id"] == UBX_ID_NAV_SVINFO:
return parse_nav_svinfo(f["payload"])
return []
# ---------------------------------------------------------------------------
# Transport-Abstraktionen
# ---------------------------------------------------------------------------
class GnssTransport:
def read(self, n: int) -> bytes:
raise NotImplementedError
def write(self, data: bytes) -> None:
raise NotImplementedError
def close(self) -> None:
raise NotImplementedError
class SerialTransport(GnssTransport):
def __init__(self, port: str, baudrate: int):
self._ser = serial.Serial(port, baudrate, timeout=1)
def read(self, n: int) -> bytes:
return self._ser.read(n)
def write(self, data: bytes) -> None:
self._ser.write(data)
def close(self) -> None:
if self._ser.is_open:
self._ser.close()
class TcpTransport(GnssTransport):
def __init__(self, host: str, port: int, timeout: float = 5.0):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(timeout)
self._sock.connect((host, port))
self._sock.settimeout(1.0)
def read(self, n: int) -> bytes:
try:
return self._sock.recv(n)
except socket.timeout:
return b""
def write(self, data: bytes) -> None:
self._sock.sendall(data)
def close(self) -> None:
try:
self._sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self._sock.close()
# ---------------------------------------------------------------------------
# GNSS Lese-Thread
# ---------------------------------------------------------------------------
class GnssReader(QThread):
satellites_updated = pyqtSignal(list)
error_occurred = pyqtSignal(str)
connected = pyqtSignal(str)
def __init__(self, transport_factory):
super().__init__()
self._factory = transport_factory
self._running = False
self._transport = None
def run(self):
self._running = True
buf = b""
try:
self._transport = self._factory()
self.connected.emit("Verbunden")
poll_msg = build_poll(UBX_CLASS_NAV, UBX_ID_NAV_SAT)
self._transport.write(poll_msg)
while self._running:
chunk = self._transport.read(512)
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)
except (serial.SerialException, OSError, ConnectionRefusedError,
socket.timeout, socket.gaierror) as exc:
self.error_occurred.emit(str(exc))
finally:
if self._transport:
self._transport.close()
def stop(self):
self._running = False
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
# ---------------------------------------------------------------------------
class SkyPlotWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._satellites = []
self.setMinimumSize(400, 400)
self.setStyleSheet("background-color: #1a1a2e;")
def update_satellites(self, sats: list):
self._satellites = sats
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
cx, cy = w / 2, h / 2
radius = min(w, h) / 2 - 30
self._draw_background(painter, cx, cy, radius)
self._draw_grid(painter, cx, cy, radius)
self._draw_satellites(painter, cx, cy, radius)
self._draw_legend(painter)
def _draw_background(self, painter, cx, cy, radius):
grad = QRadialGradient(cx, cy, radius)
grad.setColorAt(0.0, QColor("#0d1b2a"))
grad.setColorAt(1.0, QColor("#1a1a2e"))
painter.setBrush(QBrush(grad))
painter.setPen(Qt.NoPen)
painter.drawEllipse(
int(cx - radius), int(cy - radius),
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(
int(cx + math.sin(rad) * radius * 0.05),
int(cy - math.cos(rad) * radius * 0.05),
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)):
rad = math.radians(azim)
painter.drawText(
int(cx + math.sin(rad) * (radius + 15) - 6),
int(cy - math.cos(rad) * (radius + 15) + 5),
label,
)
@staticmethod
def _az_el_to_xy(azim, elev, cx, cy, radius):
r = radius * (1 - elev / 90)
rad = math.radians(azim)
return cx + math.sin(rad) * r, cy - math.cos(rad) * r
def _draw_satellites(self, painter, cx, cy, radius):
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)
glow = QColor(color)
glow.setAlpha(60)
painter.setBrush(QBrush(glow))
painter.setPen(Qt.NoPen)
painter.drawEllipse(
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))
else:
faded = QColor(color)
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.setPen(QColor("#ffffff"))
painter.setFont(QFont("Monospace", 7))
painter.drawText(int(x + dot_r + 2), int(y + 4), str(sat["svId"]))
def _draw_legend(self, painter):
painter.setFont(QFont("Monospace", 8))
x, y = 10, 20
for gnss_id, name in GNSS_NAMES.items():
color = GNSS_COLORS[gnss_id]
painter.setBrush(QBrush(color))
painter.setPen(Qt.NoPen)
painter.drawEllipse(x, y - 8, 10, 10)
painter.setPen(QColor("#cccccc"))
painter.drawText(x + 14, y, name)
y += 16
# ---------------------------------------------------------------------------
# Satelliten-Tabelle
# ---------------------------------------------------------------------------
class SatelliteTable(QTableWidget):
HEADERS = ["GNSS", "SV", "Elevation", "Azimut", "C/N₀ (dB)", "Genutzt"]
def __init__(self, parent=None):
super().__init__(0, len(self.HEADERS), parent)
self.setHorizontalHeaderLabels(self.HEADERS)
self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.setStyleSheet("""
QTableWidget {
background-color: #0d1b2a; color: #cccccc;
gridline-color: #2a4a6a; font-family: monospace; font-size: 11px;
}
QHeaderView::section {
background-color: #1a3a5c; color: #7ab7d4; padding: 4px;
}
""")
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
def update_satellites(self, sats: list):
self.setRowCount(0)
for sat in sorted(sats, key=lambda s: (s["gnssId"], s["svId"])):
row = self.rowCount()
self.insertRow(row)
color = GNSS_COLORS.get(sat["gnssId"], QColor("#ffffff"))
vals = [
GNSS_NAMES.get(sat["gnssId"], f"?({sat['gnssId']})"),
str(sat["svId"]),
f"{sat['elev']}°",
f"{sat['azim']}°",
str(sat["cno"]),
"✓" if sat["used"] else "",
]
for col, val in enumerate(vals):
item = QTableWidgetItem(val)
item.setForeground(color if col == 0 else QColor("#cccccc"))
if sat["used"]:
item.setBackground(QColor("#0d2a1a"))
self.setItem(row, col, item)
# ---------------------------------------------------------------------------
# Stil-Konstante
# ---------------------------------------------------------------------------
FIELD_STYLE = """
QLineEdit, QComboBox, QSpinBox {
background-color: #0d1b2a;
color: #cccccc;
border: 1px solid #2a4a6a;
padding: 3px 6px;
border-radius: 3px;
}
QComboBox QAbstractItemView {
background-color: #0d1b2a;
color: #cccccc;
selection-background-color: #1a3a5c;
}
"""
# ---------------------------------------------------------------------------
# Verbindungs-Panel
# ---------------------------------------------------------------------------
class ConnectionPanel(QGroupBox):
connect_requested = pyqtSignal(object, str)
disconnect_requested = pyqtSignal()
def __init__(self, parent=None):
super().__init__("Verbindung", parent)
self.setStyleSheet("""
QGroupBox { color: #7ab7d4; border: 1px solid #2a4a6a;
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)
self._tabs = QTabWidget()
self._tabs.setStyleSheet("""
QTabBar::tab { background: #1a3a5c; color: #cccccc;
padding: 4px 12px; border-radius: 3px; }
QTabBar::tab:selected { background: #2a5a8c; color: #ffffff; }
""")
# ── Tab: Seriell ─────────────────────────────────────────────────────
serial_widget = QWidget()
serial_form = QFormLayout(serial_widget)
serial_form.setContentsMargins(6, 6, 6, 6)
self._port_combo = QComboBox()
self._port_combo.setMinimumWidth(200)
refresh_btn = QPushButton("⟳")
refresh_btn.setFixedWidth(28)
refresh_btn.clicked.connect(self._refresh_ports)
port_row = QHBoxLayout()
port_row.addWidget(self._port_combo)
port_row.addWidget(refresh_btn)
serial_form.addRow("Port:", port_row)
self._baud_combo = QComboBox()
for b in ("9600", "19200", "38400", "57600", "115200", "230400", "460800"):
self._baud_combo.addItem(b)
self._baud_combo.setCurrentText("9600")
serial_form.addRow("Baudrate:", self._baud_combo)
self._tabs.addTab(serial_widget, "🔌 Seriell")
# ── Tab: TCP/IP ───────────────────────────────────────────────────────
tcp_widget = QWidget()
tcp_layout = QVBoxLayout(tcp_widget)
tcp_layout.setContentsMargins(6, 6, 6, 6)
tcp_layout.setSpacing(6)
# 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(4001)
tcp_form.addRow("Port:", self._tcp_port)
self._tcp_timeout = QSpinBox()
self._tcp_timeout.setRange(1, 30)
self._tcp_timeout.setValue(5)
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)
# Verbinden-Knopf
btn_row = QHBoxLayout()
self._connect_btn = QPushButton("▶ Verbinden")
self._connect_btn.clicked.connect(self._on_connect_clicked)
btn_row.addStretch()
btn_row.addWidget(self._connect_btn)
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()
for p in serial.tools.list_ports.comports():
self._port_combo.addItem(f"{p.device} {p.description}", p.device)
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:
port = self._port_combo.currentData()
baud = int(self._baud_combo.currentText())
if not port:
return
factory = lambda p=port, b=baud: SerialTransport(p, b)
label = f"{port} @ {baud} Baud"
else:
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}"
self.connect_requested.emit(factory, label)
def set_connected(self, connected: bool):
self._connect_btn.setText("■ Trennen" if connected else "▶ Verbinden")
self._tabs.setEnabled(not connected)
# ---------------------------------------------------------------------------
# Hauptfenster
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("GNSS Sky Plot UBX | Seriell + TCP/IP")
self.resize(980, 680)
self.setStyleSheet("background-color: #12192b; color: #cccccc;")
self._reader = None
self._build_ui()
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
# Obere Leiste
top = QHBoxLayout()
self._conn_panel = ConnectionPanel()
self._conn_panel.connect_requested.connect(self._connect)
self._conn_panel.disconnect_requested.connect(self._disconnect)
top.addWidget(self._conn_panel)
self._sat_label = QLabel("Satelliten: ")
self._sat_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._sat_label.setStyleSheet("font-size: 13px; color: #7ab7d4;")
top.addStretch()
top.addWidget(self._sat_label)
root.addLayout(top)
# Splitter: Sky Plot | Tabelle
splitter = QSplitter(Qt.Horizontal)
self._sky_plot = SkyPlotWidget()
self._table = SatelliteTable()
splitter.addWidget(self._sky_plot)
splitter.addWidget(self._table)
splitter.setSizes([540, 380])
root.addWidget(splitter)
self._status = QStatusBar()
self.setStatusBar(self._status)
self._status.showMessage("Nicht verbunden.")
def _connect(self, factory, label: str):
self._reader = GnssReader(factory)
self._reader.satellites_updated.connect(self._on_satellites)
self._reader.error_occurred.connect(self._on_error)
self._reader.connected.connect(
lambda msg: self._status.showMessage(f"{msg}: {label}")
)
self._reader.start()
self._conn_panel.set_connected(True)
self._status.showMessage(f"Verbinde mit {label} …")
def _disconnect(self):
if self._reader:
self._reader.stop()
self._reader = None
self._conn_panel.set_connected(False)
self._status.showMessage("Verbindung getrennt.")
def _on_satellites(self, sats: list):
visible = [s for s in sats if s["elev"] >= 0]
used = sum(1 for s in sats if s["used"])
self._sat_label.setText(f"Satelliten: {len(visible)} sichtbar, {used} genutzt")
self._sky_plot.update_satellites(visible)
self._table.update_satellites(visible)
def _on_error(self, msg: str):
self._status.showMessage(f"Fehler: {msg}")
self._disconnect()
def closeEvent(self, event):
self._disconnect()
super().closeEvent(event)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyle("Fusion")
win = MainWindow()
win.show()
sys.exit(app.exec_())