Files
NmeaClient/gui/data_model.py
T
jensandClaude Sonnet 5 b3447a28c5 harden UBX parsing/dispatch and report peer-side disconnects in GUI
A struct size-mismatch or any other parse error in a listener could
propagate uncaught out of the socket-reading thread and silently kill
it. Catch broadly in the event loop and per-listener in call_listener,
and make the NAV-SAT/NAV-SIG/RXM-RAWX group parsers tolerate a
payload length that isn't an exact multiple of the group size instead
of crashing on a short struct.unpack.

Also wire NetworkBackend's socket-loss detection through to
ReceiverManager (via a queued signal, since the callback fires from
the backend thread) so the GUI reflects a peer-initiated disconnect
instead of leaving the row stuck showing "connected". Verified against
a real ZED-X20P: it drops the TCP session on its own after ~20-28s
regardless of traffic; the GUI now marks the receiver disconnected and
frees its ID for reconnecting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzmCaNjDb3TAqPpTwunKTY
2026-07-15 22:00:29 +02:00

291 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 backend.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', 7: 'NavIC'
}
GNSS_SHORT = {0: 'G', 1: 'S', 2: 'E', 3: 'C', 4: 'I', 5: 'J', 6: 'R', 7: 'N'}
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
7: '#F4A0A0', # NavIC rose
}
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
file_path: str = ''
delay: float = 1.0
def display_str(self) -> str:
if self.conn_type == 'tcp':
return f"{self.receiver_id} [{self.host}:{self.port}]"
if self.conn_type == 'serial':
return f"{self.receiver_id} [{self.serial_port} @ {self.baud_rate}]"
return f"{self.receiver_id} [file: {os.path.basename(self.file_path)}]"
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)
_connection_lost = pyqtSignal(str) # internal: backend thread -> GUI thread
def __init__(self):
super().__init__()
self._states: dict = {}
self._configs: dict = {}
self._lru: list = self._load_lru()
self._connection_lost.connect(self._handle_connection_lost)
# ── 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 backend.serial_backend import SerialBackend
from backend.file_backend import FileBackend
from msg.replay_pacer import ReplayPacer
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}],
on_disconnect=lambda name, r=rid: self._connection_lost.emit(r),
)
elif config.conn_type == 'serial':
backend = SerialBackend(config.serial_port, config.baud_rate)
else:
backend = FileBackend([config.file_path])
backend.register_xcvr(xcvr)
nmea_pkt = NmeaPacketizer()
ubx_pkt = UbxPacketizer()
xcvr.register_listener(nmea_pkt)
xcvr.register_listener(ubx_pkt)
if config.conn_type == 'file' and config.delay > 0:
nmea_sink = ReplayPacer(config.delay)
ubx_sink = ReplayPacer(config.delay)
nmea_pkt.register_listener(nmea_sink)
ubx_pkt.register_listener(ubx_sink)
else:
nmea_sink = nmea_pkt
ubx_sink = ubx_pkt
ubx_sink.register_listener(
NavSatQtListener(lambda sats, r=rid: self._on_sat(r, sats))
)
nmea_sink.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
if config.conn_type != 'file':
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 _handle_connection_lost(self, rid: str):
state = self._states.get(rid)
if not state or not state.connected:
return
print(f"Receiver '{rid}': connection lost")
self.remove_receiver(rid)
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)