add UBX-MON-VER support and surface device identification in CLI/GUI

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
This commit is contained in:
2026-07-16 20:22:25 +02:00
co-authored by Claude Sonnet 5
parent d506f9afe0
commit 2dba3a9a2f
8 changed files with 176 additions and 27 deletions
+28 -4
View File
@@ -1,4 +1,4 @@
import sys, os
import sys, os, itertools
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
@@ -19,6 +19,8 @@ from backend.file_backend import source_id_from_path
BAUD_RATES = ['4800', '9600', '19200', '38400', '57600', '115200',
'230400', '460800', '921600']
_connection_counter = itertools.count(1)
class _StatusDot(QLabel):
def __init__(self):
@@ -45,10 +47,11 @@ class ConnectionRow(QWidget):
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(6)
# ── Receiver ID ──────────────────────────────────────────────────────
# ── Receiver ID (read-only: pre-filled with a counter, rewritten from
# MON-VER once the device answers, or derived from the file path) ──
layout.addWidget(QLabel("ID:"))
self._id_edit = QLineEdit()
self._id_edit.setPlaceholderText("receiver-id")
self._id_edit.setReadOnly(True)
self._id_edit.setFixedWidth(110)
layout.addWidget(self._id_edit)
@@ -125,6 +128,11 @@ class ConnectionRow(QWidget):
layout.addWidget(self._stack)
layout.addStretch()
# ── Device identification (read-only, from MON-VER on connect) ────────
self._version_label = QLabel("")
self._version_label.setStyleSheet("color: #888888; font-size: 11px;")
layout.addWidget(self._version_label)
# ── Status / buttons ─────────────────────────────────────────────────
self._dot = _StatusDot()
layout.addWidget(self._dot)
@@ -141,6 +149,8 @@ class ConnectionRow(QWidget):
if config:
self.apply_config(config)
else:
self._id_edit.setText(f"conn-{next(_connection_counter)}")
# ── File browse ──────────────────────────────────────────────────────────
@@ -199,10 +209,17 @@ class ConnectionRow(QWidget):
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,
for w in [self._type_combo, self._host_edit,
self._port_spin, self._ser_combo, self._baud_combo,
self._file_edit, self._delay_spin]:
w.setEnabled(not connected)
if not connected:
self._version_label.setText("")
def set_version_info(self, device_id: str, sw_version: str):
if device_id:
self._id_edit.setText(device_id)
self._version_label.setText(sw_version)
# ── Slots ────────────────────────────────────────────────────────────────
@@ -287,6 +304,7 @@ class ConnectionTab(QWidget):
outer.addLayout(bottom)
model.connection_changed.connect(self._on_connection_changed)
model.version_update.connect(self._on_version_update)
# ── History combo ─────────────────────────────────────────────────────────
@@ -332,3 +350,9 @@ class ConnectionTab(QWidget):
break
if connected:
self._refresh_history_combo()
def _on_version_update(self, rid: str, device_id: str, sw_version: str):
for row in self._rows:
if row.get_receiver_id() == rid:
row.set_version_info(device_id, sw_version)
break
+46 -1
View File
@@ -1,4 +1,4 @@
import sys, os, json, dataclasses, time
import sys, os, json, dataclasses, time, threading
from dataclasses import dataclass
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
@@ -13,6 +13,8 @@ from nmea.packetizer import NmeaPacketizer
from ubx.packetizer import UbxPacketizer
from backend.network_backend import NetworkBackend
from ubx.ubx import frame_create
from ubx.query import UbxQuery
from ubx.messages import Ver
GNSS_NAMES = {
0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou',
@@ -35,6 +37,14 @@ LRU_PATH = os.path.expanduser('~/.nmea_client_gui.json')
LRU_MAX = 20
def _device_id_from_ver(ver: dict) -> str:
for ext in ver.get('extensions', []):
s = ext.get('extension', '')
if s.startswith('MOD='):
return s[len('MOD='):]
return ver.get('hw_version', '')
@dataclass
class SatelliteData:
gnss_id: int
@@ -85,12 +95,15 @@ class _ReceiverState:
self.backend = None
self.transceiver = None
self.poll_timer = None
self.device_id: str = ''
self.sw_version: str = ''
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
version_update = pyqtSignal(str, str, str) # rid, device_id, sw_version
receiver_added = pyqtSignal(str)
receiver_removed = pyqtSignal(str)
_connection_lost = pyqtSignal(str) # internal: backend thread -> GUI thread
@@ -101,6 +114,7 @@ class ReceiverManager(QObject):
self._configs: dict = {}
self._lru: list = self._load_lru()
self._connection_lost.connect(self._handle_connection_lost)
self.version_update.connect(self._on_version)
# ── LRU ──────────────────────────────────────────────────────────────────
@@ -197,6 +211,11 @@ class ReceiverManager(QObject):
GsaQtListener(rid, lambda p, t, r=rid: self._on_pdop(r, p, t))
)
ver_query = None
if config.conn_type != 'file':
ver_query = UbxQuery(xcvr, Ver())
ubx_sink.register_listener(ver_query)
backend.connect()
backend.start()
@@ -211,9 +230,18 @@ class ReceiverManager(QObject):
timer.start()
state.poll_timer = timer
threading.Thread(target=self._poll_version, args=(rid, ver_query),
daemon=True).start()
self._push_lru(config)
self.connection_changed.emit(rid, True)
def _poll_version(self, rid: str, query: UbxQuery):
ver = query.poll()
if ver is None:
return
self.version_update.emit(rid, _device_id_from_ver(ver), ver['sw_version'])
def _handle_connection_lost(self, rid: str):
state = self._states.get(rid)
if not state or not state.connected:
@@ -237,7 +265,10 @@ class ReceiverManager(QObject):
state.backend = None
state.transceiver = None
state.satellites.clear()
state.device_id = ''
state.sw_version = ''
self.connection_changed.emit(rid, False)
self.version_update.emit(rid, '', '')
def _poll_ubx(self, rid: str):
state = self._states.get(rid)
@@ -263,6 +294,14 @@ class ReceiverManager(QObject):
def get_config(self, rid: str):
return self._configs.get(rid)
def get_version_info(self, rid: str):
s = self._states.get(rid)
return (s.device_id, s.sw_version) if s else ('', '')
def display_name(self, rid: str) -> str:
s = self._states.get(rid)
return (s.device_id if s and s.device_id else rid)
def get_satellites(self, rids: list) -> list:
result = []
for rid in rids:
@@ -288,3 +327,9 @@ class ReceiverManager(QObject):
return
state.pdop = pdop
self.pdop_update.emit(rid, pdop, ts)
def _on_version(self, rid: str, device_id: str, sw_version: str):
state = self._states.get(rid)
if state:
state.device_id = device_id
state.sw_version = sw_version
+11 -1
View File
@@ -316,6 +316,7 @@ class SinglePlotWidget(QWidget):
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)
# ── UI ────────────────────────────────────────────────────────────────────
@@ -425,7 +426,7 @@ class SinglePlotWidget(QWidget):
for rid in selected:
ts_dq, v_dq = self._data.get(rid, ([], []))
if ts_dq:
snapshot[rid] = (list(ts_dq), list(v_dq))
snapshot[self._model.display_name(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)
@@ -485,6 +486,15 @@ class SinglePlotWidget(QWidget):
self._rebuild_val_combo()
self._submit_render()
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
self._submit_render()
def _on_sat_update(self, rid: str, sats: list):
selected = set(self._selected_rids())
if rid not in selected:
+18 -1
View File
@@ -34,6 +34,7 @@ class SkyTab(QWidget):
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
@@ -113,6 +114,7 @@ class SkyTab(QWidget):
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 ────────────────────────────────────────────────
@@ -136,6 +138,7 @@ class SkyTab(QWidget):
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):
@@ -151,6 +154,20 @@ class SkyTab(QWidget):
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:
@@ -297,7 +314,7 @@ class SkyTab(QWidget):
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.setItem(row, 6, cell(self._model.display_name(rid)))
msg_item = QTableWidgetItem("")
msg_item.setTextAlignment(Qt.AlignCenter)
msg_item.setBackground(brush)
+31 -6
View File
@@ -3,6 +3,7 @@ import time
import json
import signal
import argparse
import threading
from backend.network_backend import NetworkBackend
from backend.serial_backend import SerialBackend
@@ -16,8 +17,9 @@ from nmea.messages import Gll, Gsa, Gsv, Gga, Rmc
from ubx.ubx import frame_create
from ubx.msg_types import *
from ubx.packetizer import UbxPacketizer
from ubx.messages import Sig, Sat, RawX, UbxMsg
from ubx.messages import Sig, Sat, RawX, Ver, UbxMsg
from ubx.listener import UbxListener
from ubx.query import UbxQuery
# ---------------------------------------------------------------------------
@@ -87,7 +89,8 @@ def _make_listeners(name: str):
def wire_source(name: str, backend, log_dir: str | None,
loggers: list, transceivers: dict, pacer_delay: float = 0.0):
loggers: list, transceivers: dict, ver_queries: dict,
pacer_delay: float = 0.0):
xcvr = Transceiver(name)
backend.register_xcvr(xcvr)
transceivers[name] = xcvr
@@ -104,6 +107,10 @@ def wire_source(name: str, backend, log_dir: str | None,
nmea_rx, ubx_rx = _make_listeners(name)
ver_query = UbxQuery(xcvr, Ver())
ubx_rx.append(ver_query)
ver_queries[name] = ver_query
if pacer_delay > 0:
nmea_pacer = ReplayPacer(pacer_delay)
ubx_pacer = ReplayPacer(pacer_delay)
@@ -157,6 +164,7 @@ if __name__ == '__main__':
backends = []
loggers = []
transceivers = {}
ver_queries = {}
# Network sources → one shared NetworkBackend
net_sources = [s for s in sources if s['type'] == 'network']
@@ -165,13 +173,14 @@ if __name__ == '__main__':
for s in net_sources])
backends.append(net_be)
for s in net_sources:
wire_source(s['name'], net_be, log_dir, loggers, transceivers)
wire_source(s['name'], net_be, log_dir, loggers, transceivers, ver_queries)
# Serial sources → one SerialBackend each
for s in [s for s in sources if s['type'] == 'serial']:
serial_sources = [s for s in sources if s['type'] == 'serial']
for s in serial_sources:
ser_be = SerialBackend(s['device'], s.get('baudrate', 115200))
backends.append(ser_be)
wire_source(s['name'], ser_be, log_dir, loggers, transceivers)
wire_source(s['name'], ser_be, log_dir, loggers, transceivers, ver_queries)
# File sources → one shared FileBackend (no secondary logging)
file_sources = [s for s in sources if s['type'] == 'file']
@@ -182,7 +191,8 @@ if __name__ == '__main__':
name = source_id_from_path(s['path'])
if name:
wire_source(name, file_be, log_dir=None, loggers=loggers,
transceivers=transceivers, pacer_delay=delay)
transceivers=transceivers, ver_queries=ver_queries,
pacer_delay=delay)
signal.signal(signal.SIGINT,
lambda sig, frm: handler(sig, frm, backends, loggers))
@@ -191,6 +201,21 @@ if __name__ == '__main__':
be.connect()
be.start()
# Poll MON-VER once per live (network/serial) source, off the main thread
# since UbxQuery.poll() blocks until the response frame arrives.
def report_version(name: str, query: UbxQuery):
ver = query.poll()
if ver is None:
return
ext = '; '.join(e['extension'] for e in ver['extensions'])
print(f"{name}: device hw={ver['hw_version']} sw={ver['sw_version']}"
+ (f" ({ext})" if ext else ""))
for s in net_sources + serial_sources:
name = s['name']
threading.Thread(target=report_version, args=(name, ver_queries[name]),
daemon=True).start()
# Poll loop only meaningful for network/serial sources
net_names = [s['name'] for s in net_sources]
if net_names:
+1
View File
@@ -1,3 +1,4 @@
from ubx.messages.msg import UbxMsg
from ubx.messages.nav import Sig, Sat
from ubx.messages.rxm import RawX
from ubx.messages.mon import Ver
+32
View File
@@ -0,0 +1,32 @@
import struct
from ubx.messages.msg import UbxMsg
class Ver(UbxMsg):
def __init__(self):
UbxMsg.__init__(self, 0x0A, 0x04)
class Group1:
@classmethod
def from_bytes(cls, data: bytes):
obj = Ver.Group1()
obj.extension = data[0:30].split(b"\x00", 1)[0].decode("ascii", errors="replace")
return obj.__dict__
@classmethod
def from_bytes(cls, data: bytes):
obj = Ver()
obj.sw_version = data[0:30].split(b"\x00", 1)[0].decode("ascii", errors="replace")
obj.hw_version = data[30:40].split(b"\x00", 1)[0].decode("ascii", errors="replace")
obj.extensions = []
offset = 40
remain = len(data) - offset
group_size = 30
n = offset
while remain >= group_size:
extension = cls.Group1.from_bytes(data[n:n + group_size])
n += group_size
remain -= group_size
obj.extensions.append(extension)
if remain:
print(f"Ver.from_bytes: {remain} trailing bytes unparsed (payload size mismatch)")
return obj.__dict__
+8 -13
View File
@@ -3,30 +3,25 @@ from ubx.ubx import frame_parse, frame_create
from ubx.messages.msg import UbxMsg
from transceiver import Transceiver
from ubx.listener import UbxListener
from threading import BoundedSemaphore
from threading import Event
class UbxQuery(UbxListener):
def __init__(self, transceiver: Transceiver, msg: UbxMsg):
UbxListener.__init__(self, msg)
self.transceiver = transceiver
self.sema = BoundedSemaphore()
self.result = None
self.event = Event()
def on_recv(self, msg: MsgContainer):
if self.is_msg(msg):
data, hdr = frame_parse(msg.data)
msg_parsed = self.msg.from_bytes(data)
self.msg = msg_parsed
self.sema.release()
self.result = self.msg.from_bytes(data)
self.event.set()
def poll(self):
self.event.clear()
frame = frame_create(self.msg.mclass, self.msg.mid, b'')
self.transceiver.send(frame)
self.sema.acquire()
print(self.msg)
def query(self, data: bytes):
frame = frame_create(0x01, 0x35, b'')
self.transceiver.send(frame)
self.sema.acquire()
print(self.msg)
self.event.wait()
return self.result