diff --git a/plot_tab.py b/plot_tab.py new file mode 100644 index 0000000..a320b19 --- /dev/null +++ b/plot_tab.py @@ -0,0 +1,298 @@ +import pyqtgraph as pg +from pyqtgraph import PlotWidget +from datetime import datetime +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QComboBox, QLabel +from PyQt5.QtCore import Qt, QSettings, pyqtSignal + +from defines import GNSS_NAMES + +pg.setConfigOption("background", "#0d1b2a") +pg.setConfigOption("foreground", "#cccccc") + +PLOT_Y_FIELDS_TOP = ["pdop", "cno", "azim", "elev"] +PLOT_Y_LABELS_TOP = { + "pdop": "PDOP", + "cno": "C/N₀ (dB)", + "azim": "Azimuth (°)", + "elev": "Elevation (°)", +} + +MAX_HISTORY = 300 + +CURVE_PALETTE = [ + "#1E90FF", "#00C853", "#E53935", "#FFA500", + "#9C27B0", "#00BCD4", "#FF4081", "#FFD600", +] + + +# --------------------------------------------------------------------------- +# Persistenz-Hilfsklasse für Plot-Einstellungen +# --------------------------------------------------------------------------- +class PlotSettingsStore: + """ + Speichert/lädt die Einstellungen aller SinglePlotPanels via QSettings. + Format pro Panel: + plot_settings/panel_/field → str (z.B. "cno") + plot_settings/panel_/sv → str (z.B. "GPS-12" oder "Alle") + """ + + _KEY = "plot_settings" + + def __init__(self): + self._cfg = QSettings("gnss_skyplot", "connections") + + def save(self, panels: list) -> None: + self._cfg.beginGroup(self._KEY) + self._cfg.remove("") # Gruppe leeren + for idx, panel in enumerate(panels): + self._cfg.setValue(f"panel_{idx}/field", panel.current_field()) + self._cfg.setValue(f"panel_{idx}/sv", panel.current_sv()) + self._cfg.endGroup() + self._cfg.sync() + + def load(self, panels: list) -> None: + self._cfg.beginGroup(self._KEY) + for idx, panel in enumerate(panels): + field = self._cfg.value(f"panel_{idx}/field", "cno") + sv = self._cfg.value(f"panel_{idx}/sv", "Alle") + panel.restore(field, sv) + self._cfg.endGroup() + + +# --------------------------------------------------------------------------- +# Einzelner Plot-Panel +# --------------------------------------------------------------------------- +class SinglePlotPanel(QWidget): + """Ein einzelner 2D-Plot mit Y-Auswahl (Feld + SVid).""" + + # Signal: Einstellung wurde geändert → PlotTab speichert + settings_changed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._history: dict[str, dict] = {} # key → {x:[], y:[], active: bool} + self._curves: dict[str, pg.PlotDataItem] = {} + self._field = "cno" + self._svid_filter = "Alle" + self._build_ui() + + # ── UI ──────────────────────────────────────────────────────────────── + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Steuerleiste + ctrl = QHBoxLayout() + ctrl.addWidget(QLabel("Y:")) + + self._y_combo = QComboBox() + self._y_combo.setFixedWidth(120) + for key in PLOT_Y_FIELDS_TOP: + self._y_combo.addItem(PLOT_Y_LABELS_TOP[key], key) + self._y_combo.currentIndexChanged.connect(self._on_field_changed) + ctrl.addWidget(self._y_combo) + + ctrl.addWidget(QLabel("SV:")) + self._sv_combo = QComboBox() + self._sv_combo.setFixedWidth(110) + self._sv_combo.addItem("Alle", "Alle") + self._sv_combo.currentIndexChanged.connect(self._on_sv_changed) + ctrl.addWidget(self._sv_combo) + + ctrl.addStretch() + layout.addLayout(ctrl) + + # Plot-Widget + self._plot = PlotWidget() + self._plot.showGrid(x=True, y=True, alpha=0.3) + self._plot.setLabel("left", PLOT_Y_LABELS_TOP[self._field]) + self._plot.setLabel("bottom", "Zeit") + self._plot.addLegend(offset=(5, 5)) + self._plot.setAxisItems({"bottom": pg.DateAxisItem(orientation="bottom")}) + layout.addWidget(self._plot) + + # ── Öffentliche Getter für Persistenz ───────────────────────────────── + + def current_field(self) -> str: + return self._field + + def current_sv(self) -> str: + return self._svid_filter + + # ── Einstellungen wiederherstellen ──────────────────────────────────── + + def restore(self, field: str, sv: str) -> None: + # Y-Combo + idx = self._y_combo.findData(field) + if idx >= 0: + self._y_combo.blockSignals(True) + self._y_combo.setCurrentIndex(idx) + self._y_combo.blockSignals(False) + self._field = field + self._plot.setLabel("left", PLOT_Y_LABELS_TOP.get(field, field)) + + # SV-Combo: Der SV-Wert wird gesetzt, sobald die Combo befüllt ist. + # Wir merken ihn vor, _update_sv_combo() wendet ihn an. + self._svid_filter = sv + + # ── Slots ───────────────────────────────────────────────────────────── + + def _on_field_changed(self, _idx: int): + self._field = self._y_combo.currentData() + self._plot.setLabel("left", PLOT_Y_LABELS_TOP[self._field]) + self._history.clear() + self._curves.clear() + self._plot.clear() + self._plot.addLegend(offset=(5, 5)) + # SV-Combo zurücksetzen + self._sv_combo.blockSignals(True) + self._sv_combo.clear() + self._sv_combo.addItem("Alle", "Alle") + self._sv_combo.blockSignals(False) + self._svid_filter = "Alle" + self.settings_changed.emit() + + def _on_sv_changed(self, _idx: int): + self._svid_filter = self._sv_combo.currentData() or "Alle" + self._redraw() + self.settings_changed.emit() + + # ── Daten-Update ────────────────────────────────────────────────────── + + def update_data(self, sats: list[dict], pdop: float | None, ts: float): + active_keys: set[str] = set() + + if self._field == "pdop": + active_keys.add("PDOP") + self._push("PDOP", ts, pdop if pdop is not None else float("nan")) + else: + for sat in sats: + key = f"{GNSS_NAMES.get(sat['gnssId'], '?')}-{sat['svId']}" + active_keys.add(key) + self._push(key, ts, float(sat.get(self._field, 0))) + + # Markiere inaktive SVs + for key, h in self._history.items(): + h["active"] = key in active_keys + + self._update_sv_combo() + self._redraw() + + def _push(self, key: str, ts: float, val: float): + if key not in self._history: + self._history[key] = {"x": [], "y": [], "active": True} + h = self._history[key] + h["x"].append(ts) + h["y"].append(val) + h["active"] = True + if len(h["x"]) > MAX_HISTORY: + h["x"] = h["x"][-MAX_HISTORY:] + h["y"] = h["y"][-MAX_HISTORY:] + + def _update_sv_combo(self): + known = {self._sv_combo.itemData(i) + for i in range(self._sv_combo.count())} + self._sv_combo.blockSignals(True) + for key in self._history: + if key not in known: + self._sv_combo.addItem(key, key) + # Gespeicherten Filter anwenden, falls SV jetzt bekannt ist + if self._svid_filter != "Alle": + idx = self._sv_combo.findData(self._svid_filter) + if idx >= 0 and self._sv_combo.currentIndex() != idx: + self._sv_combo.setCurrentIndex(idx) + self._sv_combo.blockSignals(False) + + # ── Zeichnen ────────────────────────────────────────────────────────── + + def _redraw(self): + show_keys = ( + list(self._history.keys()) + if self._svid_filter == "Alle" + else ([self._svid_filter] if self._svid_filter in self._history else []) + ) + + # Kurven entfernen, die nicht mehr im Filter sind + for key in list(self._curves.keys()): + if key not in show_keys: + self._plot.removeItem(self._curves.pop(key)) + + for idx, key in enumerate(show_keys): + if key not in self._history: + continue + h = self._history[key] + color = CURVE_PALETTE[idx % len(CURVE_PALETTE)] + active = h.get("active", True) + + if active: + pen = pg.mkPen(color=color, width=1, style=Qt.SolidLine) + else: + # Inaktiver SV: gestrichelte Linie bei y=0 + pen = pg.mkPen(color=color, width=1, style=Qt.DashLine) + x_data = h["x"] if h["x"] else [datetime.now().timestamp()] + y_data = [0.0] * len(x_data) + if key in self._curves: + self._curves[key].setData(x_data, y_data) + self._curves[key].setPen(pen) + else: + self._curves[key] = self._plot.plot( + x_data, y_data, pen=pen, name=f"{key} (weg)" + ) + continue + + if key not in self._curves: + self._curves[key] = self._plot.plot( + h["x"], h["y"], pen=pen, name=key + ) + else: + self._curves[key].setData(h["x"], h["y"]) + self._curves[key].setPen(pen) + + +# --------------------------------------------------------------------------- +# Plot-Tab (2 × 4 Grid, scrollbar) +# --------------------------------------------------------------------------- +class PlotTab(QWidget): + NUM_PLOTS = 8 + + def __init__(self, parent=None): + super().__init__(parent) + self._panels: list[SinglePlotPanel] = [] + self._store = PlotSettingsStore() + self._build_ui() + self._store.load(self._panels) # Einstellungen wiederherstellen + + def _build_ui(self): + from PyQt5.QtWidgets import QScrollArea, QGridLayout + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet("QScrollArea { border: none; }") + + grid_widget = QWidget() + grid = QGridLayout(grid_widget) + grid.setSpacing(6) + grid.setContentsMargins(6, 6, 6, 6) + + for i in range(self.NUM_PLOTS): + panel = SinglePlotPanel() + panel.settings_changed.connect(self._save_settings) + self._panels.append(panel) + row, col = divmod(i, 2) + grid.addWidget(panel, row, col) + + scroll.setWidget(grid_widget) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.addWidget(scroll) + + def _save_settings(self): + self._store.save(self._panels) + + def update_data(self, sats: list[dict], pdop: float | None = None): + ts = datetime.now().timestamp() + for panel in self._panels: + panel.update_data(sats, pdop, ts) diff --git a/requirements.txt b/requirements.txt index 912b73d..31b2030 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyserial pyqt5 +pyqtgraph diff --git a/sky_plot.py b/sky_plot.py index 53b7404..d84e209 100644 --- a/sky_plot.py +++ b/sky_plot.py @@ -8,7 +8,7 @@ TCP/IP-Verbindungen werden in einer persistenten LRU-Liste (max. 5) gespeichert. import sys from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QLabel, QStatusBar, QSplitter, ) + QHBoxLayout, QLabel, QStatusBar, QSplitter, QTabWidget) from PyQt5.QtCore import Qt from connection_panel import ConnectionPanel @@ -16,7 +16,7 @@ from gnss_thread import GnssReader from sat_table import SatelliteTable from sky_plot_widget import SkyPlotWidget - +from plot_tab import PlotTab # --------------------------------------------------------------------------- # Hauptfenster # --------------------------------------------------------------------------- @@ -35,6 +35,73 @@ class MainWindow(QMainWindow): root = QVBoxLayout(central) root.setContentsMargins(8, 8, 8, 8) + # Haupt-Tabs + self._main_tabs = QTabWidget() + self._main_tabs.setStyleSheet(""" + QTabBar::tab { + background: #1a3a5c; + color: #cccccc; + padding: 6px 20px; + border-radius: 3px; + } + QTabBar::tab:selected { + background: #2a5a8c; + color: #ffffff; + } + """) + + # ── Tab 1: Verbindung ───────────────────────────────────────────────── + conn_tab = QWidget() + conn_layout = QVBoxLayout(conn_tab) + conn_layout.setContentsMargins(8, 8, 8, 8) + + self._conn_panel = ConnectionPanel() + self._conn_panel.connect_requested.connect(self._connect) + self._conn_panel.disconnect_requested.connect(self._disconnect) + conn_layout.addWidget(self._conn_panel) + conn_layout.addStretch() + + self._main_tabs.addTab(conn_tab, "🔌 Verbindung") + + # ── Tab 2: Sky View ─────────────────────────────────────────────────── + view_tab = QWidget() + view_layout = QVBoxLayout(view_tab) + view_layout.setContentsMargins(8, 8, 8, 8) + + # ── Tab 3: Plot ─────────────────────────────────────────────────── + self._plot_tab = PlotTab() + self._main_tabs.addTab(self._plot_tab, "📈 Plot") + + # Satellitenanzahl + self._sat_label = QLabel("Satelliten: –") + self._sat_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._sat_label.setStyleSheet("font-size: 13px; color: #7ab7d4;") + view_layout.addWidget(self._sat_label) + + # Splitter: Sky Plot | Tabelle + splitter = QSplitter(Qt.Horizontal) + self._sky_plot = SkyPlotWidget() + self._table = SatelliteTable() + splitter.addWidget(self._sky_plot) + splitter.addWidget(self._table) + splitter.setSizes([540, 380]) + view_layout.addWidget(splitter) + + self._main_tabs.addTab(view_tab, "🛰 Sky View") + + root.addWidget(self._main_tabs) + + # Status Bar + self._status = QStatusBar() + self.setStatusBar(self._status) + self._status.showMessage("Nicht verbunden.") + + def __build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + # Obere Leiste top = QHBoxLayout() @@ -65,6 +132,20 @@ class MainWindow(QMainWindow): self._status.showMessage("Nicht verbunden.") def _connect(self, factory, label: str): + self._reader = GnssReader(factory) + self._reader.satellites_updated.connect(self._on_satellites) + self._reader.error_occurred.connect(self._on_error) + self._reader.connected.connect( + lambda msg: ( + self._status.showMessage(f"{msg}: {label}"), + self._main_tabs.setCurrentIndex(1), # → Sky View + ) + ) + self._reader.start() + self._conn_panel.set_connected(True) + self._status.showMessage(f"Verbinde mit {label} …") + + def __connect(self, factory, label: str): self._reader = GnssReader(factory) self._reader.satellites_updated.connect(self._on_satellites) self._reader.error_occurred.connect(self._on_error) @@ -88,6 +169,7 @@ class MainWindow(QMainWindow): self._sat_label.setText(f"Satelliten: {len(visible)} sichtbar, {used} genutzt") self._sky_plot.update_satellites(visible) self._table.update_satellites(visible) + self._plot_tab.update_data(visible) # ← neu def _on_error(self, msg: str): self._status.showMessage(f"Fehler: {msg}")