diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..4352e45 --- /dev/null +++ b/gui/__init__.py @@ -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) diff --git a/gui/backends.py b/gui/backends.py new file mode 100644 index 0000000..80f75a5 --- /dev/null +++ b/gui/backends.py @@ -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 diff --git a/gui/connection_tab.py b/gui/connection_tab.py new file mode 100644 index 0000000..4a95e37 --- /dev/null +++ b/gui/connection_tab.py @@ -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 diff --git a/gui/data_model.py b/gui/data_model.py new file mode 100644 index 0000000..f6120e5 --- /dev/null +++ b/gui/data_model.py @@ -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) diff --git a/gui/listeners.py b/gui/listeners.py new file mode 100644 index 0000000..9441754 --- /dev/null +++ b/gui/listeners.py @@ -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, 3–14=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 diff --git a/gui/main.py b/gui/main.py new file mode 100644 index 0000000..c232561 --- /dev/null +++ b/gui/main.py @@ -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() diff --git a/gui/plot_tab.py b/gui/plot_tab.py new file mode 100644 index 0000000..cb5353d --- /dev/null +++ b/gui/plot_tab.py @@ -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) diff --git a/gui/plot_widget.py b/gui/plot_widget.py new file mode 100644 index 0000000..031c555 --- /dev/null +++ b/gui/plot_widget.py @@ -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) diff --git a/gui/sky_tab.py b/gui/sky_tab.py new file mode 100644 index 0000000..bedfb89 --- /dev/null +++ b/gui/sky_tab.py @@ -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) diff --git a/gui/sky_widget.py b/gui/sky_widget.py new file mode 100644 index 0000000..4faee05 --- /dev/null +++ b/gui/sky_widget.py @@ -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, '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