Add the Ver message parser and a fixed UbxQuery (was unused and had a broken semaphore/state bug) to poll MON-VER once on connect. The CLI prints hw/sw version and extensions; the GUI polls it per receiver, pre-fills the read-only ID field with a connection counter and rewrites it from the MOD= extension once the device answers, and propagates the resolved display name to the Sky and Plot tabs so they no longer show the stale placeholder ID. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XM4FNUjhu9x8df5Dp1hHvd
325 lines
14 KiB
Python
325 lines
14 KiB
Python
import sys, os
|
|
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, QHBoxLayout, QVBoxLayout, QLabel, QListWidget,
|
|
QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView,
|
|
QSplitter, QGroupBox, QFrame, QCheckBox, QSpinBox,
|
|
)
|
|
from PyQt5.QtCore import Qt, QTimer
|
|
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', 'Msg']
|
|
_COL_MSG = len(_COL_HEADERS) - 1
|
|
_TRAIL_MAXLEN = 86400 # 24 hours at 1 Hz
|
|
_TRAIL_MAX_HOURS = 24
|
|
_MSG_HIGHLIGHT_MS = 10_000
|
|
|
|
|
|
class SkyTab(QWidget):
|
|
def __init__(self, model: ReceiverManager):
|
|
super().__init__()
|
|
self._model = model
|
|
self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData
|
|
self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id)
|
|
self._pdop: dict = {} # rid -> float
|
|
self._info_cards: dict = {} # rid -> QFrame
|
|
self._info_labels: dict = {} # rid -> (lbl_pdop, lbl_visible, lbl_used)
|
|
self._name_labels: dict = {} # rid -> QLabel (receiver display name)
|
|
self._trail: dict = {} # (rid, gnss_id, sv_id) -> deque[(elev, azim)]
|
|
self._hi_timers: dict = {} # (rid, gnss_id, sv_id) -> QTimer
|
|
|
|
outer = QVBoxLayout(self)
|
|
outer.setContentsMargins(4, 4, 4, 4)
|
|
outer.setSpacing(4)
|
|
|
|
# ── Source selector + trail toggle ────────────────────────────────────
|
|
src_box = QGroupBox("Receiver Sources (check to include)")
|
|
src_box.setMaximumHeight(80)
|
|
src_bl = QHBoxLayout(src_box)
|
|
src_bl.setContentsMargins(4, 2, 4, 2)
|
|
src_bl.setSpacing(8)
|
|
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_immediate)
|
|
src_bl.addWidget(self._src_list)
|
|
|
|
self._trail_cb = QCheckBox("Show Trails")
|
|
self._trail_cb.setChecked(False)
|
|
self._trail_cb.toggled.connect(self._on_trail_toggled)
|
|
src_bl.addWidget(self._trail_cb)
|
|
|
|
self._trail_spin = QSpinBox()
|
|
self._trail_spin.setRange(0, _TRAIL_MAX_HOURS)
|
|
self._trail_spin.setValue(1)
|
|
self._trail_spin.setSuffix(" h")
|
|
self._trail_spin.setFixedWidth(58)
|
|
self._trail_spin.setEnabled(False)
|
|
self._trail_spin.valueChanged.connect(self._on_trail_toggled)
|
|
src_bl.addWidget(self._trail_spin)
|
|
outer.addWidget(src_box)
|
|
|
|
# ── Info bar (one card per receiver) ──────────────────────────────────
|
|
info_frame = QFrame()
|
|
info_frame.setFrameShape(QFrame.StyledPanel)
|
|
info_frame.setMaximumHeight(42)
|
|
self._info_layout = QHBoxLayout(info_frame)
|
|
self._info_layout.setContentsMargins(6, 2, 6, 2)
|
|
self._info_layout.setSpacing(12)
|
|
self._info_layout.addStretch()
|
|
outer.addWidget(info_frame)
|
|
|
|
# ── 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)
|
|
hdr = self._table.horizontalHeader()
|
|
hdr.setSectionResizeMode(QHeaderView.ResizeToContents)
|
|
hdr.setSectionResizeMode(_COL_MSG, QHeaderView.Fixed)
|
|
hdr.setStretchLastSection(False)
|
|
self._table.setColumnWidth(_COL_MSG, 36)
|
|
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)
|
|
|
|
# ── Table throttle timer (max ~2 Hz rebuilds) ─────────────────────────
|
|
self._table_timer = QTimer(self)
|
|
self._table_timer.setSingleShot(True)
|
|
self._table_timer.setInterval(500)
|
|
self._table_timer.timeout.connect(self._rebuild_table_from_store)
|
|
|
|
# ── 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)
|
|
model.pdop_update.connect(self._on_pdop_update)
|
|
model.version_update.connect(self._on_version_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)
|
|
|
|
card = QFrame()
|
|
card.setFrameShape(QFrame.Box)
|
|
cl = QHBoxLayout(card)
|
|
cl.setContentsMargins(8, 1, 8, 1)
|
|
cl.setSpacing(10)
|
|
name_lbl = QLabel(f"<b>{rid}</b>")
|
|
lbl_pdop = QLabel("pDOP: —")
|
|
lbl_visible = QLabel("Visible: —")
|
|
lbl_used = QLabel("Used: —")
|
|
for w in (name_lbl, lbl_pdop, lbl_visible, lbl_used):
|
|
cl.addWidget(w)
|
|
self._info_cards[rid] = card
|
|
self._info_labels[rid] = (lbl_pdop, lbl_visible, lbl_used)
|
|
self._name_labels[rid] = name_lbl
|
|
self._info_layout.insertWidget(self._info_layout.count() - 1, card)
|
|
|
|
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
|
|
for k in [k for k in self._store if k[0] == rid]: del self._store[k]
|
|
for k in [k for k in self._trail if k[0] == rid]: del self._trail[k]
|
|
self._pdop.pop(rid, None)
|
|
card = self._info_cards.pop(rid, None)
|
|
if card:
|
|
self._info_layout.removeWidget(card)
|
|
card.deleteLater()
|
|
self._info_labels.pop(rid, None)
|
|
self._name_labels.pop(rid, None)
|
|
self._refresh_immediate()
|
|
|
|
def _on_version_update(self, rid: str, device_id: str, sw_version: str):
|
|
label = self._model.display_name(rid)
|
|
for i in range(self._src_list.count()):
|
|
item = self._src_list.item(i)
|
|
if item.data(Qt.UserRole) == rid:
|
|
item.setText(f" {label} ")
|
|
break
|
|
lbl = self._name_labels.get(rid)
|
|
if lbl:
|
|
lbl.setText(f"<b>{label}</b>")
|
|
self._table_keys = [] # force a full rebuild so the Receiver column updates
|
|
self._refresh_immediate()
|
|
|
|
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
|
|
|
|
def _visible_sats(self) -> list:
|
|
selected = self._selected_rids()
|
|
return [(k[0], self._store[k])
|
|
for k in self._store if k[0] in selected]
|
|
|
|
def _active_trails(self) -> dict:
|
|
if not self._trail_cb.isChecked():
|
|
return {}
|
|
n_points = self._trail_spin.value() * 3600
|
|
if n_points == 0:
|
|
return {}
|
|
selected = self._selected_rids()
|
|
return {key: list(dq)[-n_points:]
|
|
for key, dq in self._trail.items()
|
|
if key[0] in selected}
|
|
|
|
# ── Trail toggle ──────────────────────────────────────────────────────────
|
|
|
|
def _on_trail_toggled(self, _value=None):
|
|
self._trail_spin.setEnabled(self._trail_cb.isChecked())
|
|
self._sky.update_data(self._visible_sats(), self._active_trails())
|
|
|
|
# ── Info bar ──────────────────────────────────────────────────────────────
|
|
|
|
def _update_info(self):
|
|
for rid, (lbl_pdop, lbl_visible, lbl_used) in self._info_labels.items():
|
|
sats = [self._store[k] for k in self._store if k[0] == rid]
|
|
n_visible = len(sats)
|
|
n_used = sum(1 for s in sats if s.used_in_fix)
|
|
pdop = self._pdop.get(rid)
|
|
lbl_pdop.setText( f"pDOP: {pdop:.1f}" if pdop is not None else "pDOP: —")
|
|
lbl_visible.setText(f"Visible: {n_visible}")
|
|
lbl_used.setText( f"Used: {n_used}")
|
|
|
|
# ── Data update ───────────────────────────────────────────────────────────
|
|
|
|
def _on_sat_update(self, rid: str, sats: list):
|
|
selected = self._selected_rids()
|
|
for sat in sats:
|
|
key = (rid, sat.gnss_id, sat.sv_id)
|
|
self._store[key] = sat
|
|
if key not in self._trail:
|
|
self._trail[key] = deque(maxlen=_TRAIL_MAXLEN)
|
|
self._trail[key].append((sat.elevation, sat.azimuth))
|
|
if rid in selected:
|
|
self._highlight_row(key)
|
|
|
|
self._sky.update_data(self._visible_sats(), self._active_trails())
|
|
self._update_info()
|
|
if not self._table_timer.isActive():
|
|
self._table_timer.start()
|
|
|
|
def _on_pdop_update(self, rid: str, pdop: float, _ts: float):
|
|
self._pdop[rid] = pdop
|
|
self._update_info()
|
|
|
|
def _refresh_immediate(self):
|
|
self._table_timer.stop()
|
|
visible = self._visible_sats()
|
|
self._sky.update_data(visible, self._active_trails())
|
|
self._update_info()
|
|
self._rebuild_table(visible)
|
|
|
|
def _rebuild_table_from_store(self):
|
|
self._rebuild_table(self._visible_sats())
|
|
|
|
# ── Row highlight on message receive ─────────────────────────────────────
|
|
|
|
def _highlight_row(self, key):
|
|
try:
|
|
row = self._table_keys.index(key)
|
|
except ValueError:
|
|
return # row not in table yet (full rebuild pending)
|
|
item = self._table.item(row, _COL_MSG)
|
|
if item:
|
|
item.setText("✓")
|
|
t = self._hi_timers.get(key)
|
|
if t:
|
|
t.start(_MSG_HIGHLIGHT_MS)
|
|
else:
|
|
t = QTimer(self)
|
|
t.setSingleShot(True)
|
|
t.timeout.connect(lambda k=key, r=row: self._clear_highlight(k, r))
|
|
t.start(_MSG_HIGHLIGHT_MS)
|
|
self._hi_timers[key] = t
|
|
|
|
def _clear_highlight(self, key, row):
|
|
self._hi_timers.pop(key, None)
|
|
if row >= self._table.rowCount() or row >= len(self._table_keys):
|
|
return
|
|
if self._table_keys[row] != key:
|
|
return # row was reassigned during a full rebuild
|
|
item = self._table.item(row, _COL_MSG)
|
|
if item:
|
|
item.setText("")
|
|
|
|
# ── Table ─────────────────────────────────────────────────────────────────
|
|
|
|
def _rebuild_table(self, sats: list):
|
|
sorted_sats = sorted(sats, key=lambda x: (x[1].gnss_id, x[1].sv_id))
|
|
new_keys = [(rid, sat.gnss_id, sat.sv_id) for rid, sat in sorted_sats]
|
|
|
|
if new_keys == self._table_keys:
|
|
# Only values changed — update in place, scroll position untouched
|
|
for row, (_, sat) in enumerate(sorted_sats):
|
|
self._table.item(row, 2).setText(f"{sat.elevation:.0f}")
|
|
self._table.item(row, 3).setText(f"{sat.azimuth:.0f}")
|
|
self._table.item(row, 4).setText(f"{sat.cno:.1f}")
|
|
self._table.item(row, 5).setText("✓" if sat.used_in_fix else "")
|
|
return
|
|
|
|
# Satellite set changed — full rebuild
|
|
self._table_keys = new_keys
|
|
vp = self._table.viewport()
|
|
vp.setUpdatesEnabled(False)
|
|
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(self._model.display_name(rid)))
|
|
msg_item = QTableWidgetItem("")
|
|
msg_item.setTextAlignment(Qt.AlignCenter)
|
|
msg_item.setBackground(brush)
|
|
self._table.setItem(row, _COL_MSG, msg_item)
|
|
|
|
self._table.setSortingEnabled(True)
|
|
vp.setUpdatesEnabled(True)
|