It still described network_backend.py at the repo root as the entry point and said nothing about the GUI, the serial/file backends, or how backend threads now handle parse errors vs. real disconnects. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzmCaNjDb3TAqPpTwunKTY
7.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this project is
A Python client for u-blox GNSS receivers (NEO-F9P, ZED-X20P) connected over TCP/IP, serial, or replayed from a log file. It receives and parses both NMEA 0183 and UBX binary protocol messages, and can send UBX poll requests to the receivers.
Hardware reference documentation and CLI usage examples are in readme.md (u-blox datasheets, integration manuals, interface specs, RTK/RTCM references).
Running
# CLI — headless, prints parsed messages to stdout
python main.py --network neo-f9p 192.168.22.93 8721 \
--network zed-x20p 192.168.22.93 8731
# GUI — PyQt5 app with Connection / Sky / Plot tabs
python gui/main.py
main.py accepts --network NAME HOST PORT, --serial NAME DEVICE BAUD, --file PATH (repeatable/combinable), --project FILE / --save FILE for JSON config, --log-dir DIR and --delay SECS for file replay pacing. See readme.md for the full option table and a project-file example.
Architecture
The design follows a listener/talker pipeline pattern:
ABackend (Network | Serial | File)
└─> Transceiver (one per receiver, named to match the backend's source)
├─> NmeaPacketizer ──> NmeaListener subclasses (Gga, Gll, Gsa, Gsv, Rmc)
└─> UbxPacketizer ──> UbxListener instances (wrapping UbxMsg subclasses)
Core abstractions (msg/):
MsgContainer— wraps rawbyteswith asource(ATransceiver) and timestampMsgListener— ABC withon_recv(msg)andis_msg(msg)(default: False); only called whenis_msgreturns TrueMsgTalker— holds a list ofMsgListenersinks;call_listeneriterates them, callingis_msg/on_recvper sink inside atry/exceptso one misbehaving listener can't kill the caller (important: e.g.UbxPacketizer.on_recvonly resets its packet buffer aftercall_listenerreturns)FrameLogger— writes every raw frame for a source to a timestamped log file underlog_dir, for later replayReplayPacer— sits between a packetizer and its listeners during file replay; sleepsdelayseconds per complete frame to pace playback
Transceiver (transceiver.py): inherits both ATransceiver (MsgListener) and MsgTalker. Receives raw bytes from the backend via on_recv, wraps them in MsgContainer, and fans out to downstream listeners. Sends bytes back via backend.send.
Backends (backend/), each implementing ABackend.send(xcvr, data):
NetworkBackend— non-blocking TCP sockets viaselectors, one shared selector/thread per backend instance (the CLI wires all--networksources into one sharedNetworkBackend; the GUI creates oneNetworkBackendper receiver so connections are fully isolated). Callregister_xcvr(xcvr)to bind a transceiver to its socket by name match, thenconnect()+start()to begin the event loop thread. The event loop catchesOSError(peer closed / connection refused) to cleanly unregister and close that socket without killing the thread, and catches any otherExceptionfrom downstream processing so a parse error in a listener can't silently kill the reader thread either. Accepts an optionalon_disconnect(name)callback, invoked when a socket is lost — the GUI uses this to reflect a peer-initiated disconnect inReceiverManagerinstead of leaving stale "connected" state.SerialBackend— reads/writes a pyserialSerialport on a polling threadFileBackend— replays one or more raw log files (as produced byFrameLogger) chunk-by-chunk on a thread per file;source_id_from_pathextracts the receiver name from theYYYY_MM_DD_HH_MM_SS_<name>.logfilename convention
NMEA parsing (nmea/):
NmeaPacketizer: buffers bytes and emits aMsgContainerper line (CR/LF delimited)NmeaListener/AsciiListener: filters by regex on the raw bytes; subclasses overrideon_recvto parse CSV fields- Message classes (
nmea/messages/):Gga,Gll,Gsa,Gsv,Rmc— each takes(name, tid)wheretidis the talker ID (e.g.'GN','GP'); use'--'to match any talker
UBX parsing (ubx/):
ubx.py:frame_create(class, id, data)builds a framed UBX packet with Fletcher checksum;frame_parse/frame_parse_hdrdecode received framesUbxPacketizer: buffers bytes, detects sync word0xB5 0x62, reassembles complete frames by checking the length field and verifying checksumUbxMsg(base class inubx/messages/msg.py): holdsmclass/mid; subclasses implementfrom_bytes(data)as a classmethodUbxListener: wraps aUbxMsg, matches by class+id inis_msg, parses and prints inon_recvUbxQuery(ubx/query.py): extendsUbxListenerwith a semaphore-based synchronouspoll()— sends a UBX poll frame and blocks until the response arrives
Implemented UBX messages (ubx/messages/):
nav.py:Sig(0x01/0x43 NAV-SIG),Sat(0x01/0x35 NAV-SAT) — repeated-group parsers walkremain >= group_size(not a bare truthy check) so a payload whose length isn't an exact multiple of the per-item size logs a warning and stops instead of raisingstruct.erroron a short slicerxm.py:RawX(0x02/0x15 RXM-RAWX) — same defensive group parsing
GUI (gui/)
A PyQt5 app (gui/main.py) on top of the same msg-pipeline, with Connection / Sky / Plot tabs in a QTabWidget.
data_model.py—ReceiverManager(QObject)owns per-receiver state (_ReceiverState: backend, transceiver, satellites, pdop, connected flag) and exposes Qt signals (connection_changed,satellite_update,pdop_update,receiver_added/removed) that GUI widgets connect to.connect_receiver/disconnect_receiver/remove_receiverbuild the backend + packetizer + listener chain for TCP/Serial/File sources. A peer-initiated disconnect (NetworkBackend'son_disconnectcallback, fired from the backend thread) is marshaled to the GUI thread via an internal_connection_lostsignal and handled by tearing the receiver down the same way an explicit disconnect does.ConnectionConfig+ an LRU history persisted to~/.nmea_client_gui.jsonround out connection setup.listeners.py—NavSatQtListener(UBX NAV-SAT →SatelliteDatalist) andGsaQtListener(NMEA GSA → pDOP) bridge parsed messages toReceiverManagercallbacks.connection_tab.py—ConnectionTab+ConnectionRow: one row per receiver, TCP (host/port) / Serial (port/baud) / File (path/delay) source types, LRU history combo.sky_tab.py/sky_widget.py— polar sky-plot (off-threadQPainterrendering, no external plotting deps), satellite table, per-satellite trails, GNSS-source filter checkboxes.plot_tab.py/plot_widget.py— 8 independent time-series plots (customQPaintercanvas, no pyqtgraph) in a scrollable 2-column grid; settings persisted to~/.nmea_client_plots.json.
Rendering that isn't trivial should stay off the GUI thread (see the sky/plot widgets for the established pattern), and table widgets should update cells in place rather than rebuilding on every message to preserve scroll position.
Error-handling philosophy
Backend-thread code (the socket reader loops) should never die from an application-level parsing bug — only from a genuine, expected OSError on the socket itself, which is treated as "this connection is gone" and reported via on_disconnect where a caller is listening. Anything else raised by downstream listener/parser code should be caught, logged, and skipped so one malformed or unexpected message doesn't take down the whole read loop.
Adding a new UBX message
- Create a subclass of
UbxMsgwith the correctmclass/midand afrom_bytesclassmethod - Instantiate
UbxListener(YourMsg())and register it on theUbxPacketizer
Adding a new NMEA sentence
Subclass NmeaListener with the appropriate msg_type string, override on_recv to split on b',' and populate a Msg dataclass, then register on the NmeaPacketizer.