added gui
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+51
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user