added gui

This commit is contained in:
2026-05-24 11:52:03 +02:00
parent 74b949f864
commit 561d4ad77f
10 changed files with 1592 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import sys, os
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # nmea_client/
_gui = os.path.dirname(os.path.abspath(__file__)) # nmea_client/gui/
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
+66
View File
@@ -0,0 +1,66 @@
import sys, os
from threading import Thread
from queue import Queue, Empty
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from a_backend import ABackend
from a_transceiver import ATransceiver
from msg.container import MsgContainer
class SerialBackend(ABackend):
"""Backend that reads from a serial (COM / tty) port."""
def __init__(self, port: str, baud_rate: int = 115200):
self._port = port
self._baud = baud_rate
self._serial = None
self._xcvr = None
self._running = False
self._queue: Queue = Queue()
self._thread = Thread(target=self._run, daemon=True)
def register_xcvr(self, xcvr):
self._xcvr = xcvr
xcvr.on_register(self)
def send(self, xcvr: ATransceiver, data: bytes):
self._queue.put(data)
def connect(self):
import serial
self._serial = serial.Serial(self._port, self._baud, timeout=0.1)
self._running = True
def start(self):
self._thread.start()
def stop(self):
self._running = False
if self._thread.is_alive():
self._thread.join(timeout=2.0)
def disconnect(self):
if self._serial and self._serial.is_open:
self._serial.close()
def _run(self):
while self._running:
try:
data = self._serial.read(64)
if data and self._xcvr:
self._xcvr.on_recv(MsgContainer(data, self._xcvr))
# Drain outgoing queue
while True:
try:
self._serial.write(self._queue.get_nowait())
except Empty:
break
except Exception:
self._running = False
break
+282
View File
@@ -0,0 +1,282 @@
import sys, os
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel,
QLineEdit, QComboBox, QSpinBox, QStackedWidget, QScrollArea,
QFrame, QMessageBox, QSizePolicy,
)
from PyQt5.QtCore import pyqtSignal, Qt
from data_model import ReceiverManager, ConnectionConfig
BAUD_RATES = ['4800', '9600', '19200', '38400', '57600', '115200',
'230400', '460800', '921600']
class _StatusDot(QLabel):
def __init__(self):
super().__init__("")
self.setFixedWidth(20)
self.setAlignment(Qt.AlignCenter)
self.set_connected(False)
def set_connected(self, connected: bool):
color = '#4CAF50' if connected else '#9E9E9E'
self.setStyleSheet(f"color: {color}; font-size: 16px;")
class ConnectionRow(QWidget):
remove_requested = pyqtSignal(object)
def __init__(self, model: ReceiverManager, config: ConnectionConfig = None):
super().__init__()
self._model = model
self._rid: str = None
self._connected = False
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(6)
# ── History dropdown ─────────────────────────────────────────────────
self._hist_combo = QComboBox()
self._hist_combo.setPlaceholderText("History ▾")
self._hist_combo.setMinimumWidth(150)
self._hist_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self._hist_combo.currentIndexChanged.connect(self._load_history)
layout.addWidget(self._hist_combo)
sep = QFrame()
sep.setFrameShape(QFrame.VLine)
sep.setFrameShadow(QFrame.Sunken)
layout.addWidget(sep)
# ── Receiver ID ──────────────────────────────────────────────────────
layout.addWidget(QLabel("ID:"))
self._id_edit = QLineEdit()
self._id_edit.setPlaceholderText("receiver-id")
self._id_edit.setFixedWidth(110)
layout.addWidget(self._id_edit)
# ── Connection type ──────────────────────────────────────────────────
self._type_combo = QComboBox()
self._type_combo.addItems(['TCP', 'Serial'])
self._type_combo.currentIndexChanged.connect(self._on_type_changed)
layout.addWidget(self._type_combo)
# ── Stacked: TCP page / Serial page ─────────────────────────────────
self._stack = QStackedWidget()
# TCP page
tcp_w = QWidget()
tcp_l = QHBoxLayout(tcp_w)
tcp_l.setContentsMargins(0, 0, 0, 0)
tcp_l.setSpacing(4)
tcp_l.addWidget(QLabel("Host:"))
self._host_edit = QLineEdit("192.168.1.1")
self._host_edit.setFixedWidth(130)
tcp_l.addWidget(self._host_edit)
tcp_l.addWidget(QLabel("Port:"))
self._port_spin = QSpinBox()
self._port_spin.setRange(1, 65535)
self._port_spin.setValue(8721)
self._port_spin.setFixedWidth(75)
tcp_l.addWidget(self._port_spin)
self._stack.addWidget(tcp_w)
# Serial page
ser_w = QWidget()
ser_l = QHBoxLayout(ser_w)
ser_l.setContentsMargins(0, 0, 0, 0)
ser_l.setSpacing(4)
ser_l.addWidget(QLabel("Port:"))
self._ser_combo = QComboBox()
self._ser_combo.setEditable(True)
self._ser_combo.setMinimumWidth(130)
self._populate_serial_ports()
ser_l.addWidget(self._ser_combo)
ser_l.addWidget(QLabel("Baud:"))
self._baud_combo = QComboBox()
self._baud_combo.addItems(BAUD_RATES)
self._baud_combo.setCurrentText('115200')
ser_l.addWidget(self._baud_combo)
self._stack.addWidget(ser_w)
layout.addWidget(self._stack)
layout.addStretch()
# ── Status / buttons ─────────────────────────────────────────────────
self._dot = _StatusDot()
layout.addWidget(self._dot)
self._conn_btn = QPushButton("Connect")
self._conn_btn.setFixedWidth(95)
self._conn_btn.clicked.connect(self._toggle)
layout.addWidget(self._conn_btn)
rm_btn = QPushButton("Remove")
rm_btn.setFixedWidth(70)
rm_btn.clicked.connect(lambda: self.remove_requested.emit(self))
layout.addWidget(rm_btn)
if config:
self.apply_config(config)
self.refresh_history()
# ── Serial port detection ────────────────────────────────────────────────
def _populate_serial_ports(self):
try:
import serial.tools.list_ports
ports = [p.device for p in serial.tools.list_ports.comports()]
except Exception:
ports = []
self._ser_combo.addItems(ports or ['/dev/ttyUSB0', 'COM1'])
# ── Config helpers ───────────────────────────────────────────────────────
def apply_config(self, config: ConnectionConfig):
self._id_edit.setText(config.receiver_id)
self._type_combo.setCurrentIndex(0 if config.conn_type == 'tcp' else 1)
self._host_edit.setText(config.host)
self._port_spin.setValue(config.port)
if config.serial_port:
idx = self._ser_combo.findText(config.serial_port)
self._ser_combo.setCurrentIndex(idx) if idx >= 0 \
else self._ser_combo.setCurrentText(config.serial_port)
self._baud_combo.setCurrentText(str(config.baud_rate))
def get_config(self) -> ConnectionConfig:
t = 'tcp' if self._type_combo.currentIndex() == 0 else 'serial'
return ConnectionConfig(
receiver_id = self._id_edit.text().strip(),
conn_type = t,
host = self._host_edit.text().strip(),
port = self._port_spin.value(),
serial_port = self._ser_combo.currentText().strip(),
baud_rate = int(self._baud_combo.currentText()),
)
def get_receiver_id(self) -> str:
return self._rid
def refresh_history(self):
self._hist_combo.blockSignals(True)
self._hist_combo.clear()
self._hist_combo.addItem("History ▾")
for cfg in self._model.get_lru():
self._hist_combo.addItem(cfg.display_str(), userData=cfg)
self._hist_combo.blockSignals(False)
def set_connected(self, connected: bool):
self._connected = connected
self._dot.set_connected(connected)
self._conn_btn.setText("Disconnect" if connected else "Connect")
for w in [self._id_edit, self._type_combo, self._host_edit,
self._port_spin, self._ser_combo, self._baud_combo]:
w.setEnabled(not connected)
# ── Slots ────────────────────────────────────────────────────────────────
def _on_type_changed(self, idx: int):
self._stack.setCurrentIndex(idx)
def _load_history(self, idx: int):
if idx <= 0:
return
cfg = self._hist_combo.itemData(idx)
if cfg:
self.apply_config(cfg)
self._hist_combo.blockSignals(True)
self._hist_combo.setCurrentIndex(0)
self._hist_combo.blockSignals(False)
def _toggle(self):
if self._connected:
if self._rid:
self._model.disconnect_receiver(self._rid)
return
config = self.get_config()
if not config.receiver_id:
QMessageBox.warning(self, "GNSS Monitor",
"Receiver-ID darf nicht leer sein.")
return
if config.receiver_id in self._model.get_receiver_ids():
QMessageBox.warning(self, "GNSS Monitor",
f"ID '{config.receiver_id}' wird bereits verwendet.")
return
self._model.add_receiver(config)
self._rid = config.receiver_id
try:
self._model.connect_receiver(config.receiver_id)
except Exception as exc:
QMessageBox.critical(self, "Verbindungsfehler", str(exc))
self._model.remove_receiver(config.receiver_id)
self._rid = None
# ── Connection tab ────────────────────────────────────────────────────────────
class ConnectionTab(QWidget):
def __init__(self, model: ReceiverManager):
super().__init__()
self._model = model
self._rows: list = []
outer = QVBoxLayout(self)
outer.setContentsMargins(8, 8, 8, 8)
outer.setSpacing(6)
# Scroll area
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._inner = QWidget()
self._vbox = QVBoxLayout(self._inner)
self._vbox.setContentsMargins(0, 0, 0, 0)
self._vbox.setSpacing(2)
self._vbox.addStretch()
self._scroll.setWidget(self._inner)
outer.addWidget(self._scroll)
add_btn = QPushButton(" Add Connection")
add_btn.setMaximumWidth(180)
add_btn.clicked.connect(lambda: self._add_row())
outer.addWidget(add_btn, alignment=Qt.AlignLeft)
model.connection_changed.connect(self._on_connection_changed)
def _add_row(self, config: ConnectionConfig = None):
row = ConnectionRow(self._model, config)
row.remove_requested.connect(self._remove_row)
# Insert before the trailing stretch
self._vbox.insertWidget(self._vbox.count() - 1, row)
self._rows.append(row)
def _remove_row(self, row: ConnectionRow):
rid = row.get_receiver_id()
if rid:
self._model.remove_receiver(rid)
self._vbox.removeWidget(row)
row.deleteLater()
if row in self._rows:
self._rows.remove(row)
def _on_connection_changed(self, rid: str, connected: bool):
for row in self._rows:
if row.get_receiver_id() == rid:
row.set_connected(connected)
if connected:
for r in self._rows:
r.refresh_history()
break
+261
View File
@@ -0,0 +1,261 @@
import sys, os, json, dataclasses, time
from dataclasses import dataclass
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from transceiver import Transceiver
from nmea.packetizer import NmeaPacketizer
from ubx.packetizer import UbxPacketizer
from network_backend import NetworkBackend
from ubx.ubx import frame_create
GNSS_NAMES = {
0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou',
4: 'IMES', 5: 'QZSS', 6: 'GLONASS',
}
GNSS_SHORT = {0: 'G', 1: 'S', 2: 'E', 3: 'C', 4: 'I', 5: 'J', 6: 'R'}
GNSS_COLORS = {
0: '#2196F3', # GPS blue
1: '#9E9E9E', # SBAS gray
2: '#4CAF50', # Galileo green
3: '#FF9800', # BeiDou orange
4: '#607D8B', # IMES blue-gray
5: '#9C27B0', # QZSS purple
6: '#F44336', # GLONASS red
}
LRU_PATH = os.path.expanduser('~/.nmea_client_gui.json')
LRU_MAX = 20
@dataclass
class SatelliteData:
gnss_id: int
sv_id: int
cno: float
elevation: float
azimuth: float
used_in_fix: bool
def sat_label(self) -> str:
name = GNSS_NAMES.get(self.gnss_id, f'SYS{self.gnss_id}')
return f"{name}-{self.sv_id}"
@dataclass
class ConnectionConfig:
receiver_id: str
conn_type: str = 'tcp'
host: str = ''
port: int = 8721
serial_port: str = ''
baud_rate: int = 115200
def display_str(self) -> str:
if self.conn_type == 'tcp':
return f"{self.receiver_id} [{self.host}:{self.port}]"
return f"{self.receiver_id} [{self.serial_port} @ {self.baud_rate}]"
def to_dict(self):
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, d: dict) -> 'ConnectionConfig':
valid = {f.name for f in dataclasses.fields(cls)}
return cls(**{k: v for k, v in d.items() if k in valid})
class _ReceiverState:
def __init__(self, rid: str):
self.receiver_id = rid
self.satellites: dict = {} # (gnss_id, sv_id) -> SatelliteData
self.pdop: float = 0.0
self.connected: bool = False
self.backend = None
self.transceiver = None
self.poll_timer = None
class ReceiverManager(QObject):
satellite_update = pyqtSignal(str, list) # rid, [SatelliteData]
pdop_update = pyqtSignal(str, float, float) # rid, pdop, timestamp
connection_changed = pyqtSignal(str, bool) # rid, connected
receiver_added = pyqtSignal(str)
receiver_removed = pyqtSignal(str)
def __init__(self):
super().__init__()
self._states: dict = {}
self._configs: dict = {}
self._lru: list = self._load_lru()
# ── LRU ──────────────────────────────────────────────────────────────────
def _load_lru(self) -> list:
try:
with open(LRU_PATH) as f:
return [ConnectionConfig.from_dict(d)
for d in json.load(f).get('lru', [])]
except Exception:
return []
def _save_lru(self):
try:
with open(LRU_PATH, 'w') as f:
json.dump({'lru': [c.to_dict() for c in self._lru[:LRU_MAX]]},
f, indent=2)
except Exception:
pass
def _push_lru(self, config: ConnectionConfig):
key = config.display_str()
self._lru = [c for c in self._lru if c.display_str() != key]
self._lru.insert(0, config)
self._lru = self._lru[:LRU_MAX]
self._save_lru()
def get_lru(self) -> list:
return list(self._lru)
# ── Receiver lifecycle ────────────────────────────────────────────────────
def add_receiver(self, config: ConnectionConfig):
rid = config.receiver_id
if rid in self._states:
return
self._configs[rid] = config
self._states[rid] = _ReceiverState(rid)
self.receiver_added.emit(rid)
def update_config(self, config: ConnectionConfig):
rid = config.receiver_id
if rid in self._states and not self._states[rid].connected:
self._configs[rid] = config
def remove_receiver(self, rid: str):
self.disconnect_receiver(rid)
self._states.pop(rid, None)
self._configs.pop(rid, None)
self.receiver_removed.emit(rid)
def connect_receiver(self, rid: str):
from listeners import NavSatQtListener, GsaQtListener
from backends import SerialBackend
config = self._configs.get(rid)
state = self._states.get(rid)
if not config or not state or state.connected:
return
xcvr = Transceiver(rid)
if config.conn_type == 'tcp':
backend = NetworkBackend(
[{'name': rid, 'host': config.host, 'port': config.port}]
)
else:
backend = SerialBackend(config.serial_port, config.baud_rate)
backend.register_xcvr(xcvr)
nmea_pkt = NmeaPacketizer()
ubx_pkt = UbxPacketizer()
xcvr.register_listener(nmea_pkt)
xcvr.register_listener(ubx_pkt)
ubx_pkt.register_listener(
NavSatQtListener(lambda sats, r=rid: self._on_sat(r, sats))
)
nmea_pkt.register_listener(
GsaQtListener(rid, lambda p, t, r=rid: self._on_pdop(r, p, t))
)
backend.connect()
backend.start()
state.backend = backend
state.transceiver = xcvr
state.connected = True
# Poll UBX messages every second (receiver only sends on request by default)
timer = QTimer(self)
timer.setInterval(1000)
timer.timeout.connect(lambda r=rid: self._poll_ubx(r))
timer.start()
state.poll_timer = timer
self._push_lru(config)
self.connection_changed.emit(rid, True)
def disconnect_receiver(self, rid: str):
state = self._states.get(rid)
if not state or not state.connected:
return
if state.poll_timer:
state.poll_timer.stop()
state.poll_timer = None
try:
state.backend.stop()
state.backend.disconnect()
except Exception:
pass
state.connected = False
state.backend = None
state.transceiver = None
state.satellites.clear()
self.connection_changed.emit(rid, False)
def _poll_ubx(self, rid: str):
state = self._states.get(rid)
if not state or not state.connected or not state.transceiver:
return
try:
for cls_id, msg_id in [(0x01, 0x43), # NAV-SIG
(0x01, 0x35), # NAV-SAT
(0x02, 0x15)]: # RXM-RAWX
state.transceiver.send(frame_create(cls_id, msg_id))
except Exception:
pass
# ── Queries ───────────────────────────────────────────────────────────────
def get_receiver_ids(self) -> list:
return list(self._states.keys())
def is_connected(self, rid: str) -> bool:
s = self._states.get(rid)
return s.connected if s else False
def get_config(self, rid: str):
return self._configs.get(rid)
def get_satellites(self, rids: list) -> list:
result = []
for rid in rids:
state = self._states.get(rid)
if state:
for sat in state.satellites.values():
result.append((rid, sat))
return result
# ── Internal signal emission (called from backend threads) ─────────────
def _on_sat(self, rid: str, sats: list):
state = self._states.get(rid)
if not state:
return
for s in sats:
state.satellites[(s.gnss_id, s.sv_id)] = s
self.satellite_update.emit(rid, list(state.satellites.values()))
def _on_pdop(self, rid: str, pdop: float, ts: float):
state = self._states.get(rid)
if not state:
return
state.pdop = pdop
self.pdop_update.emit(rid, pdop, ts)
+58
View File
@@ -0,0 +1,58 @@
import sys, os, time
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from msg.container import MsgContainer
from ubx.listener import UbxListener
from ubx.messages.nav import Sat
from ubx.ubx import frame_parse
from nmea.listener import NmeaListener
from data_model import SatelliteData
class NavSatQtListener(UbxListener):
"""Bridges UBX NAV-SAT (0x01/0x35) to a Python callback."""
def __init__(self, callback):
super().__init__(Sat())
self._cb = callback
def on_recv(self, msg: MsgContainer):
data, _ = frame_parse(msg.data)
if data is None:
return
parsed = Sat.from_bytes(data)
sats = []
for s in parsed.get('satellites', []):
sats.append(SatelliteData(
gnss_id = s['gnss_id'],
sv_id = s['sv_id'],
cno = float(s['cno']),
elevation = float(s['elev']),
azimuth = float(s['azim'] % 360),
used_in_fix = bool(s['flags'] & (1 << 3)),
))
self._cb(sats)
class GsaQtListener(NmeaListener):
"""Bridges NMEA GSA (any talker) to a Python callback providing (pdop, timestamp)."""
def __init__(self, name: str, callback):
NmeaListener.__init__(self, name, tid='--', msg_type='GSA')
self._cb = callback
def on_recv(self, msg: MsgContainer):
fields = msg.data.split(b',')
# GSA field layout (0-indexed): 0=msg_type, 1=mode, 2=fix, 314=sv_ids,
# 15=pDOP, 16=hDOP, 17=vDOP[*checksum]
if len(fields) >= 16:
try:
pdop = float(fields[15])
self._cb(pdop, time.time())
except (ValueError, IndexError):
pass
+51
View File
@@ -0,0 +1,51 @@
import sys, os
# Path setup — works both as `python gui/main.py` and `python -m gui.main`
for _p in [
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # nmea_client/
os.path.dirname(os.path.abspath(__file__)), # nmea_client/gui/
]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import QApplication, QMainWindow, QTabWidget
from PyQt5.QtGui import QIcon
from data_model import ReceiverManager
from connection_tab import ConnectionTab
from sky_tab import SkyTab
from plot_tab import PlotTab
class MainWindow(QMainWindow):
def __init__(self, model: ReceiverManager):
super().__init__()
self._model = model
self.setWindowTitle("GNSS Monitor")
self.resize(1400, 900)
tabs = QTabWidget()
tabs.addTab(ConnectionTab(model), "Connection")
tabs.addTab(SkyTab(model), "Sky")
tabs.addTab(PlotTab(model), "Plot")
self.setCentralWidget(tabs)
def closeEvent(self, event):
for rid in list(self._model.get_receiver_ids()):
self._model.disconnect_receiver(rid)
event.accept()
def main():
app = QApplication(sys.argv)
app.setStyle('Fusion')
model = ReceiverManager()
win = MainWindow(model)
win.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
+47
View File
@@ -0,0 +1,47 @@
import sys, os
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import (
QWidget, QScrollArea, QGridLayout, QVBoxLayout, QFrame,
)
from PyQt5.QtCore import Qt
from data_model import ReceiverManager
from plot_widget import SinglePlotWidget
class PlotTab(QWidget):
NUM_PLOTS = 8
def __init__(self, model: ReceiverManager):
super().__init__()
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
container = QWidget()
grid = QGridLayout(container)
grid.setContentsMargins(4, 4, 4, 4)
grid.setSpacing(6)
for i in range(self.NUM_PLOTS):
frame = QFrame()
frame.setFrameShape(QFrame.StyledPanel)
frame.setFrameShadow(QFrame.Raised)
fl = QVBoxLayout(frame)
fl.setContentsMargins(2, 2, 2, 2)
pw = SinglePlotWidget(model)
pw.setMinimumHeight(230)
fl.addWidget(pw)
grid.addWidget(frame, i // 2, i % 2)
scroll.setWidget(container)
outer.addWidget(scroll)
+445
View File
@@ -0,0 +1,445 @@
import sys, os, math, time
from datetime import datetime
from queue import Queue, Empty
from collections import deque
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
QSpinBox, QListWidget, QListWidgetItem, QSizePolicy, QApplication,
)
from PyQt5.QtGui import QPainter, QColor, QPen, QBrush, QFont, QImage
from PyQt5.QtCore import Qt, QPointF, QRectF, QThread, pyqtSignal
from data_model import ReceiverManager
_Y_OPTIONS = ['C/N₀', 'Azimuth', 'Elevation', 'pDOP']
_COLORS = ['#2196F3', '#F44336', '#4CAF50', '#FF9800',
'#9C27B0', '#00BCD4', '#FFEB3B', '#795548']
# Static colors — created once in main thread, read-only everywhere else
_C_BG = QColor('#1a1a2e')
_C_PLOT_BG = QColor('#12121e')
_C_GRID = QColor('#2a2a4a')
_C_AXIS = QColor('#555577')
_C_LABEL = QColor('#aaaacc')
_C_TEXT = QColor('#ddddee')
_QCOLORS = [QColor(c) for c in _COLORS]
# ── Tick helper ───────────────────────────────────────────────────────────────
def _nice_ticks(lo: float, hi: float, n: int = 5) -> list:
if hi - lo < 1e-9:
hi = lo + 1.0
raw = (hi - lo) / n
exp = math.floor(math.log10(raw))
base = 10 ** exp
step = base * 10
for m in (1, 2, 2.5, 5, 10):
if base * m >= raw:
step = base * m
break
first = math.ceil(lo / step) * step
ticks, v = [], first
while v <= hi + step * 0.01:
ticks.append(v)
v += step
return ticks
# ── Render thread ─────────────────────────────────────────────────────────────
class PlotRenderThread(QThread):
"""Renders a time-series plot to QImage in a background thread.
snapshot format: dict[rid -> (list[timestamp_float], list[value_float])]
"""
frame_ready = pyqtSignal(object) # QImage
def __init__(self, parent=None):
super().__init__(parent)
self._queue = Queue(maxsize=1)
self._running = True
def submit(self, snapshot: dict, y_label: str, width: int, height: int):
try:
self._queue.get_nowait() # discard stale pending frame
except Empty:
pass
self._queue.put((snapshot, y_label, width, height))
def stop(self):
self._running = False
try:
self._queue.put_nowait(None)
except Exception:
pass
self.wait(2000)
# ── Thread body ───────────────────────────────────────────────────────────
def run(self):
while self._running:
try:
item = self._queue.get(timeout=0.2)
except Empty:
continue
if item is None:
break
snapshot, y_label, w, h = item
if w > 0 and h > 0:
self.frame_ready.emit(self._render(snapshot, y_label, w, h))
# ── Drawing (QPainter on QImage is thread-safe) ───────────────────────────
_ML, _MR, _MT, _MB = 56, 8, 10, 36 # margins: left/right/top/bottom px
def _render(self, snapshot: dict, y_label: str, w: int, h: int) -> QImage:
img = QImage(w, h, QImage.Format_RGB32)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
ML, MR, MT, MB = self._ML, self._MR, self._MT, self._MB
pw = w - ML - MR
ph = h - MT - MB
p.fillRect(0, 0, w, h, _C_BG)
if pw > 0 and ph > 0:
p.fillRect(ML, MT, pw, ph, _C_PLOT_BG)
if pw <= 0 or ph <= 0:
p.end()
return img
all_ts = [t for ts, _ in snapshot.values() for t in ts]
all_v = [v for _, vs in snapshot.values() for v in vs]
if not all_ts:
self._draw_empty(p, ML, MT, pw, ph)
p.end()
return img
ts_min = min(all_ts); ts_max = max(all_ts)
v_min = min(all_v); v_max = max(all_v)
if ts_max - ts_min < 1:
ts_min = ts_max - 1.0
v_ticks = _nice_ticks(v_min, v_max)
v_min, v_max = v_ticks[0], v_ticks[-1]
if v_max - v_min < 1e-9:
v_max = v_min + 1.0
ts_span = ts_max - ts_min
v_span = v_max - v_min
def px(ts, v):
x = ML + (ts - ts_min) / ts_span * pw
y = MT + ph - (v - v_min) / v_span * ph
return x, y
self._draw_grid(p, ML, MT, pw, ph, v_ticks, v_min, v_max)
self._draw_curves(p, snapshot, px)
self._draw_legend(p, snapshot, ML, MT)
self._draw_y_labels(p, ML, MT, ph, v_ticks, v_min, v_max, y_label)
self._draw_x_labels(p, ML, MT, pw, ph, ts_min, ts_max)
p.end()
return img
def _draw_empty(self, p, ml, mt, pw, ph):
f = QFont(); f.setPointSize(8)
p.setFont(f); p.setPen(QPen(_C_LABEL))
p.drawText(QRectF(ml, mt, pw, ph), Qt.AlignCenter,
"Keine Daten\nQuelle + Satellit auswählen")
def _draw_grid(self, p, ml, mt, pw, ph, v_ticks, v_min, v_max):
p.setPen(QPen(_C_GRID, 1, Qt.DotLine))
v_span = v_max - v_min
for v in v_ticks:
if v_min <= v <= v_max:
y = mt + ph - (v - v_min) / v_span * ph
p.drawLine(QPointF(ml, y), QPointF(ml + pw, y))
for i in range(1, 5):
x = ml + i * pw / 5
p.drawLine(QPointF(x, mt), QPointF(x, mt + ph))
p.setPen(QPen(_C_AXIS, 1))
p.setBrush(Qt.NoBrush)
p.drawRect(ml, mt, pw, ph)
def _draw_curves(self, p, snapshot, px):
for i, (rid, (ts_list, v_list)) in enumerate(snapshot.items()):
if len(ts_list) < 1:
continue
pen = QPen(_QCOLORS[i % len(_QCOLORS)], 1.5)
p.setPen(pen)
pts = [QPointF(*px(t, v)) for t, v in zip(ts_list, v_list)]
for j in range(1, len(pts)):
p.drawLine(pts[j - 1], pts[j])
def _draw_legend(self, p, snapshot, ml, mt):
f = QFont(); f.setPointSize(7); p.setFont(f)
lx, ly = ml + 5, mt + 5
for i, rid in enumerate(snapshot):
color = _QCOLORS[i % len(_QCOLORS)]
p.setPen(QPen(color, 2))
p.drawLine(QPointF(lx, ly + 5), QPointF(lx + 14, ly + 5))
p.setPen(QPen(_C_TEXT))
p.drawText(QRectF(lx + 16, ly, 80, 12), Qt.AlignLeft, str(rid))
lx += max(20 + len(rid) * 6, 70)
def _draw_y_labels(self, p, ml, mt, ph, v_ticks, v_min, v_max, y_label):
f = QFont(); f.setPointSize(7); p.setFont(f)
p.setPen(QPen(_C_LABEL))
v_span = v_max - v_min
for v in v_ticks:
if v_min <= v <= v_max:
y = mt + ph - (v - v_min) / v_span * ph
lbl = f"{v:.1f}" if abs(v) < 1000 else f"{v:.0f}"
p.drawText(QRectF(2, y - 8, ml - 4, 16),
Qt.AlignRight | Qt.AlignVCenter, lbl)
# rotated axis label
p.save()
p.translate(10, mt + ph / 2)
p.rotate(-90)
f2 = QFont(); f2.setPointSize(8); p.setFont(f2)
p.drawText(QRectF(-40, -10, 80, 20), Qt.AlignCenter, y_label)
p.restore()
def _draw_x_labels(self, p, ml, mt, pw, ph, ts_min, ts_max):
f = QFont(); f.setPointSize(7); p.setFont(f)
p.setPen(QPen(_C_LABEL))
for i in range(6):
ts = ts_min + (ts_max - ts_min) * i / 5
x = ml + i * pw / 5
lbl = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
p.drawText(QRectF(x - 28, mt + ph + 4, 56, 14),
Qt.AlignCenter, lbl)
# ── Canvas widget (displays QImage from render thread) ────────────────────────
class _Canvas(QWidget):
resized = pyqtSignal(int, int)
def __init__(self):
super().__init__()
self._image = None
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMinimumHeight(150)
def set_image(self, img: QImage):
self._image = img
self.update()
def paintEvent(self, _event):
p = QPainter(self)
if self._image and not self._image.isNull():
p.drawImage(0, 0, self._image)
else:
p.fillRect(self.rect(), _C_BG)
p.end()
def resizeEvent(self, event):
super().resizeEvent(event)
self.resized.emit(self.width(), self.height())
# ── Single configurable plot ──────────────────────────────────────────────────
class SinglePlotWidget(QWidget):
def __init__(self, model: ReceiverManager):
super().__init__()
self._model = model
self._data: dict = {} # rid → [deque(ts), deque(val)]
self._known_sats: dict = {} # rid → set[sat_label] (selected only)
self._build_ui()
self._thread = PlotRenderThread()
self._thread.frame_ready.connect(self._canvas.set_image)
self._thread.start()
app = QApplication.instance()
if app:
app.aboutToQuit.connect(self._thread.stop)
model.receiver_added.connect(self._on_receiver_added)
model.receiver_removed.connect(self._on_receiver_removed)
model.satellite_update.connect(self._on_sat_update)
model.pdop_update.connect(self._on_pdop_update)
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(2)
bar = QHBoxLayout()
bar.setSpacing(5)
bar.addWidget(QLabel("Y-Achse:"))
self._y_cb = QComboBox(); self._y_cb.addItems(_Y_OPTIONS)
self._y_cb.setMaximumWidth(85)
self._y_cb.currentTextChanged.connect(self._on_y_changed)
bar.addWidget(self._y_cb)
bar.addWidget(QLabel("Satellit:"))
self._val_cb = QComboBox()
self._val_cb.setMinimumWidth(90); self._val_cb.setMaximumWidth(120)
self._val_cb.currentTextChanged.connect(self._submit_render)
bar.addWidget(self._val_cb)
bar.addWidget(QLabel("Quelle:"))
self._src_list = QListWidget()
self._src_list.setFlow(QListWidget.LeftToRight)
self._src_list.setWrapping(True)
self._src_list.setResizeMode(QListWidget.Adjust)
self._src_list.setMaximumHeight(46); self._src_list.setMaximumWidth(200)
self._src_list.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._src_list.itemChanged.connect(self._on_source_changed)
bar.addWidget(self._src_list)
bar.addWidget(QLabel("Punkte:"))
self._hist_spin = QSpinBox()
self._hist_spin.setRange(10, 10000); self._hist_spin.setValue(300)
self._hist_spin.setMaximumWidth(70)
self._hist_spin.valueChanged.connect(self._on_history_changed)
bar.addWidget(self._hist_spin)
bar.addStretch()
layout.addLayout(bar)
self._canvas = _Canvas()
self._canvas.resized.connect(lambda w, h: self._submit_render())
layout.addWidget(self._canvas)
# ── Source helpers ────────────────────────────────────────────────────────
def _selected_rids(self) -> list:
result = []
for i in range(self._src_list.count()):
it = self._src_list.item(i)
if it.checkState() == Qt.Checked:
result.append(it.data(Qt.UserRole))
return result
def _rebuild_val_combo(self):
selected = set(self._selected_rids())
current = self._val_cb.currentText()
self._val_cb.blockSignals(True)
self._val_cb.clear()
sats = set()
for rid in selected:
sats.update(self._known_sats.get(rid, set()))
for s in sorted(sats):
self._val_cb.addItem(s)
if current in sats:
self._val_cb.setCurrentText(current)
self._val_cb.blockSignals(False)
# ── Render submission ─────────────────────────────────────────────────────
def _submit_render(self):
selected = set(self._selected_rids())
snapshot = {}
for rid in selected:
ts_dq, v_dq = self._data.get(rid, ([], []))
if ts_dq:
snapshot[rid] = (list(ts_dq), list(v_dq))
w, h = self._canvas.width(), self._canvas.height()
if w > 0 and h > 0:
self._thread.submit(snapshot, self._y_cb.currentText(), w, h)
def _push_point(self, rid: str, ts: float, val: float):
if rid not in self._data:
n = self._hist_spin.value()
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
self._data[rid][0].append(ts)
self._data[rid][1].append(val)
self._submit_render()
# ── Config change handlers ────────────────────────────────────────────────
def _on_y_changed(self, _text: str):
is_pdop = (_text == 'pDOP')
self._val_cb.setEnabled(not is_pdop)
for rid in list(self._data):
n = self._hist_spin.value()
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
self._submit_render()
def _on_source_changed(self):
self._rebuild_val_combo()
self._submit_render()
def _on_history_changed(self, n: int):
for rid in list(self._data):
old_ts, old_v = self._data[rid]
self._data[rid] = [deque(old_ts, maxlen=n), deque(old_v, maxlen=n)]
self._submit_render()
# ── Model signals ─────────────────────────────────────────────────────────
def _on_receiver_added(self, rid: str):
item = QListWidgetItem(f" {rid} ")
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
item.setData(Qt.UserRole, rid)
self._src_list.addItem(item)
self._known_sats[rid] = set()
n = self._hist_spin.value()
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
def _on_receiver_removed(self, rid: str):
for i in range(self._src_list.count()):
if self._src_list.item(i).data(Qt.UserRole) == rid:
self._src_list.takeItem(i)
break
self._known_sats.pop(rid, None)
self._data.pop(rid, None)
self._rebuild_val_combo()
self._submit_render()
def _on_sat_update(self, rid: str, sats: list):
selected = set(self._selected_rids())
if rid not in selected:
return
y_axis = self._y_cb.currentText()
if y_axis == 'pDOP':
return
# Update satellite combo
new_labels = {s.sat_label() for s in sats}
if not new_labels.issubset(self._known_sats.get(rid, set())):
self._known_sats[rid] = self._known_sats.get(rid, set()) | new_labels
self._rebuild_val_combo()
target = self._val_cb.currentText()
if not target:
return
ts_now = time.time()
for sat in sats:
if sat.sat_label() != target:
continue
val = {'C/N₀': sat.cno, 'Azimuth': sat.azimuth,
'Elevation': sat.elevation}.get(y_axis)
if val is not None:
self._push_point(rid, ts_now, val)
break
def _on_pdop_update(self, rid: str, pdop: float, ts: float):
if self._y_cb.currentText() != 'pDOP':
return
if rid not in set(self._selected_rids()):
return
self._push_point(rid, ts, pdop)
+142
View File
@@ -0,0 +1,142 @@
import sys, os
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QLabel, QListWidget,
QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView,
QSplitter, QGroupBox,
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor, QBrush
from data_model import ReceiverManager, GNSS_NAMES, GNSS_COLORS
from sky_widget import SkyPlotWidget
_COL_HEADERS = ['GNSS', 'SV', 'Elev °', 'Az °', 'C/N₀', 'Fix', 'Receiver']
class SkyTab(QWidget):
def __init__(self, model: ReceiverManager):
super().__init__()
self._model = model
self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData
outer = QVBoxLayout(self)
outer.setContentsMargins(4, 4, 4, 4)
outer.setSpacing(4)
# ── Source selector ───────────────────────────────────────────────────
src_box = QGroupBox("Receiver Sources (check to include)")
src_box.setMaximumHeight(80)
src_bl = QHBoxLayout(src_box)
src_bl.setContentsMargins(4, 2, 4, 2)
self._src_list = QListWidget()
self._src_list.setFlow(QListWidget.LeftToRight)
self._src_list.setWrapping(True)
self._src_list.setResizeMode(QListWidget.Adjust)
self._src_list.itemChanged.connect(self._refresh)
src_bl.addWidget(self._src_list)
outer.addWidget(src_box)
# ── Sky plot + satellite table ─────────────────────────────────────────
splitter = QSplitter(Qt.Horizontal)
self._sky = SkyPlotWidget()
splitter.addWidget(self._sky)
self._table = QTableWidget()
self._table.setColumnCount(len(_COL_HEADERS))
self._table.setHorizontalHeaderLabels(_COL_HEADERS)
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setAlternatingRowColors(False)
splitter.addWidget(self._table)
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 1)
outer.addWidget(splitter)
# ── Model signals ─────────────────────────────────────────────────────
model.receiver_added.connect(self._on_receiver_added)
model.receiver_removed.connect(self._on_receiver_removed)
model.satellite_update.connect(self._on_sat_update)
# ── Source list management ────────────────────────────────────────────────
def _on_receiver_added(self, rid: str):
item = QListWidgetItem(f" {rid} ")
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
item.setData(Qt.UserRole, rid)
self._src_list.addItem(item)
def _on_receiver_removed(self, rid: str):
for i in range(self._src_list.count()):
if self._src_list.item(i).data(Qt.UserRole) == rid:
self._src_list.takeItem(i)
break
keys = [k for k in self._store if k[0] == rid]
for k in keys:
del self._store[k]
self._refresh()
def _selected_rids(self) -> set:
result = set()
for i in range(self._src_list.count()):
item = self._src_list.item(i)
if item.checkState() == Qt.Checked:
result.add(item.data(Qt.UserRole))
return result
# ── Data update ───────────────────────────────────────────────────────────
def _on_sat_update(self, rid: str, sats: list):
for sat in sats:
self._store[(rid, sat.gnss_id, sat.sv_id)] = sat
self._refresh()
def _refresh(self):
selected = self._selected_rids()
visible = [(rid, self._store[k])
for k in self._store
if (rid := k[0]) in selected]
self._sky.update_data(visible)
self._rebuild_table(visible)
# ── Table ─────────────────────────────────────────────────────────────────
def _rebuild_table(self, sats: list):
sorted_sats = sorted(sats, key=lambda x: (x[1].gnss_id, x[1].sv_id))
self._table.setSortingEnabled(False)
self._table.setRowCount(len(sorted_sats))
for row, (rid, sat) in enumerate(sorted_sats):
gnss_name = GNSS_NAMES.get(sat.gnss_id, f'SYS{sat.gnss_id}')
bg = QColor(GNSS_COLORS.get(sat.gnss_id, '#9E9E9E'))
bg.setAlpha(55)
brush = QBrush(bg)
def cell(text, br=brush):
it = QTableWidgetItem(str(text))
it.setTextAlignment(Qt.AlignCenter)
it.setBackground(br)
return it
self._table.setItem(row, 0, cell(gnss_name))
self._table.setItem(row, 1, cell(sat.sv_id))
self._table.setItem(row, 2, cell(f"{sat.elevation:.0f}"))
self._table.setItem(row, 3, cell(f"{sat.azimuth:.0f}"))
self._table.setItem(row, 4, cell(f"{sat.cno:.1f}"))
self._table.setItem(row, 5, cell("" if sat.used_in_fix else ""))
self._table.setItem(row, 6, cell(rid))
self._table.setSortingEnabled(True)
+232
View File
@@ -0,0 +1,232 @@
import sys, os, math
from queue import Queue, Empty
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import QWidget, QSizePolicy, QApplication
from PyQt5.QtGui import (QPainter, QColor, QPen, QBrush, QFont, QImage)
from PyQt5.QtCore import Qt, QPointF, QRectF, QThread, pyqtSignal
from data_model import GNSS_NAMES, GNSS_COLORS, GNSS_SHORT
# ── Static colors (created once in main thread, read-only in render thread) ──
_C_BG = QColor('#1a1a2e')
_C_RING = QColor('#3a3a5e')
_C_SPOKE = QColor('#2e2e4e')
_C_LABEL = QColor('#aaaacc')
_C_SAT_LBL = QColor('#ddddee')
_C_ZENITH = QColor('#444466')
# Pre-build GNSS QColor objects
_GNSS_QCOLORS: dict = {gid: QColor(hex_)
for gid, hex_ in GNSS_COLORS.items()}
def _polar_xy(cx, cy, max_r, elev, azim):
r = (90.0 - elev) / 90.0 * max_r
rad = math.radians(azim)
return cx + r * math.sin(rad), cy - r * math.cos(rad)
# ── Render thread ─────────────────────────────────────────────────────────────
class SkyRenderThread(QThread):
"""Renders the sky plot to a QImage in a background thread."""
frame_ready = pyqtSignal(object) # QImage — use object to avoid type-reg issues
def __init__(self, parent=None):
super().__init__(parent)
# maxsize=1: submit() always replaces pending work with latest data
self._queue: Queue = Queue(maxsize=1)
self._running = True
def submit(self, sats: list, width: int, height: int):
# Discard any pending (not yet rendered) frame so we always render latest
try:
self._queue.get_nowait()
except Empty:
pass
self._queue.put((list(sats), width, height))
def stop(self):
self._running = False
try:
self._queue.put_nowait(None) # unblock get()
except Exception:
pass
self.wait(2000)
# ── Thread body ───────────────────────────────────────────────────────────
def run(self):
while self._running:
try:
item = self._queue.get(timeout=0.2)
except Empty:
continue
if item is None:
break
sats, w, h = item
if w > 0 and h > 0:
img = self._render(sats, w, h)
self.frame_ready.emit(img)
# ── Drawing (runs in render thread — QPainter on QImage is thread-safe) ──
def _render(self, sats: list, w: int, h: int) -> QImage:
img = QImage(w, h, QImage.Format_RGB32)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
cx, cy = w / 2.0, h / 2.0
max_r = min(w, h) / 2.0 - 32
self._draw_bg(p, w, h)
self._draw_grid(p, cx, cy, max_r)
self._draw_satellites(p, cx, cy, max_r, sats)
self._draw_legend(p, sats)
p.end()
return img
def _draw_bg(self, p: QPainter, w: int, h: int):
p.fillRect(0, 0, w, h, _C_BG)
def _draw_grid(self, p: QPainter, cx, cy, max_r):
# Elevation rings 0°, 30°, 60°
p.setPen(QPen(_C_RING, 1, Qt.DashLine))
p.setBrush(Qt.NoBrush)
for elev in (0, 30, 60):
r = (90 - elev) / 90.0 * max_r
p.drawEllipse(QPointF(cx, cy), r, r)
# Azimuth spokes every 45°
p.setPen(QPen(_C_SPOKE, 1))
for az in range(0, 360, 45):
rad = math.radians(az)
p.drawLine(QPointF(cx, cy),
QPointF(cx + max_r * math.sin(rad),
cy - max_r * math.cos(rad)))
# Cardinal labels
f = QFont(); f.setPointSize(9); f.setBold(True)
p.setFont(f)
p.setPen(QPen(_C_LABEL))
off = max_r + 16
for lbl, az in (('N', 0), ('E', 90), ('S', 180), ('W', 270)):
rad = math.radians(az)
x = cx + off * math.sin(rad)
y = cy - off * math.cos(rad)
p.drawText(QRectF(x - 10, y - 10, 20, 20), Qt.AlignCenter, lbl)
# Elevation ring labels
f2 = QFont(); f2.setPointSize(7)
p.setFont(f2)
p.setPen(QPen(_C_LABEL))
for elev, lbl in ((0, ''), (30, '30°'), (60, '60°')):
r = (90 - elev) / 90.0 * max_r
p.drawText(QRectF(cx + 3, cy - r - 10, 28, 12), Qt.AlignLeft, lbl)
# Zenith dot
p.setBrush(QBrush(_C_ZENITH))
p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(cx, cy), 3, 3)
def _draw_satellites(self, p: QPainter, cx, cy, max_r, sats):
f = QFont(); f.setPointSize(7)
p.setFont(f)
for _rid, sat in sats:
color = _GNSS_QCOLORS.get(sat.gnss_id, _C_LABEL)
x, y = _polar_xy(cx, cy, max_r, sat.elevation, sat.azimuth)
size = max(4.0, min(14.0, 4.0 + (sat.cno - 20.0) * 0.33))
if sat.used_in_fix:
p.setBrush(QBrush(color))
p.setPen(QPen(color.darker(160), 1.0))
else:
p.setBrush(Qt.NoBrush)
p.setPen(QPen(color, 1.5, Qt.DashLine))
p.drawEllipse(QPointF(x, y), size, size)
short = GNSS_SHORT.get(sat.gnss_id, '?')
p.setPen(QPen(_C_SAT_LBL))
p.drawText(QRectF(x + size + 1, y - 6, 28, 12),
Qt.AlignLeft, f"{short}{sat.sv_id}")
def _draw_legend(self, p: QPainter, sats):
used = {sat.gnss_id for _, sat in sats}
if not used:
return
f = QFont(); f.setPointSize(8)
p.setFont(f)
lx, ly = 8, 8
for gid in sorted(used):
color = _GNSS_QCOLORS.get(gid)
if color is None:
continue
p.setBrush(QBrush(color))
p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(lx + 5, ly + 6), 5, 5)
p.setPen(QPen(_C_SAT_LBL))
p.drawText(lx + 14, ly + 11, GNSS_NAMES.get(gid, f'SYS{gid}'))
ly += 18
# ── Widget ────────────────────────────────────────────────────────────────────
class SkyPlotWidget(QWidget):
"""Displays the pre-rendered QImage from SkyRenderThread.
paintEvent only does a single drawImage — never blocks the main thread."""
def __init__(self):
super().__init__()
self._sats: list = []
self._image: QImage = None
self.setMinimumSize(400, 400)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self._thread = SkyRenderThread()
self._thread.frame_ready.connect(self._on_frame)
self._thread.start()
# Stop thread when application quits
app = QApplication.instance()
if app:
app.aboutToQuit.connect(self._thread.stop)
# ── Public API ────────────────────────────────────────────────────────────
def update_data(self, sats: list):
self._sats = sats
self._thread.submit(sats, self.width(), self.height())
# ── Qt events ─────────────────────────────────────────────────────────────
def resizeEvent(self, event):
super().resizeEvent(event)
if self._sats:
self._thread.submit(self._sats, self.width(), self.height())
def paintEvent(self, _event):
p = QPainter(self)
if self._image and not self._image.isNull():
p.drawImage(0, 0, self._image)
else:
p.fillRect(self.rect(), _C_BG)
p.end()
# ── Render thread callback (main thread via queued signal) ────────────────
def _on_frame(self, img):
self._image = img
self.update() # schedules repaint — returns immediately