diff --git a/gui/connection_tab.py b/gui/connection_tab.py index 7f3a4f2..33cf0e4 100644 --- a/gui/connection_tab.py +++ b/gui/connection_tab.py @@ -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 diff --git a/gui/data_model.py b/gui/data_model.py index d7671ec..6d84e6c 100644 --- a/gui/data_model.py +++ b/gui/data_model.py @@ -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 diff --git a/gui/plot_widget.py b/gui/plot_widget.py index 9c08d5e..356c171 100644 --- a/gui/plot_widget.py +++ b/gui/plot_widget.py @@ -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: diff --git a/gui/sky_tab.py b/gui/sky_tab.py index 8dc4c9b..9f114bf 100644 --- a/gui/sky_tab.py +++ b/gui/sky_tab.py @@ -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"{label}") + 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) diff --git a/main.py b/main.py index 3e557e0..93a274a 100644 --- a/main.py +++ b/main.py @@ -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: diff --git a/ubx/messages/__init__.py b/ubx/messages/__init__.py index 04ab0fa..b8172ac 100644 --- a/ubx/messages/__init__.py +++ b/ubx/messages/__init__.py @@ -1,3 +1,4 @@ from ubx.messages.msg import UbxMsg from ubx.messages.nav import Sig, Sat -from ubx.messages.rxm import RawX \ No newline at end of file +from ubx.messages.rxm import RawX +from ubx.messages.mon import Ver \ No newline at end of file diff --git a/ubx/messages/mon.py b/ubx/messages/mon.py new file mode 100644 index 0000000..927f256 --- /dev/null +++ b/ubx/messages/mon.py @@ -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__ \ No newline at end of file diff --git a/ubx/query.py b/ubx/query.py index c96600b..798a7c2 100644 --- a/ubx/query.py +++ b/ubx/query.py @@ -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