Files
jensandClaude Sonnet 5 d506f9afe0 docs: refresh CLAUDE.md to match current codebase
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
2026-07-15 22:03:40 +02:00

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 raw bytes with a source (ATransceiver) and timestamp
  • MsgListener — ABC with on_recv(msg) and is_msg(msg) (default: False); only called when is_msg returns True
  • MsgTalker — holds a list of MsgListener sinks; call_listener iterates them, calling is_msg/on_recv per sink inside a try/except so one misbehaving listener can't kill the caller (important: e.g. UbxPacketizer.on_recv only resets its packet buffer after call_listener returns)
  • FrameLogger — writes every raw frame for a source to a timestamped log file under log_dir, for later replay
  • ReplayPacer — sits between a packetizer and its listeners during file replay; sleeps delay seconds 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 via selectors, one shared selector/thread per backend instance (the CLI wires all --network sources into one shared NetworkBackend; the GUI creates one NetworkBackend per receiver so connections are fully isolated). Call register_xcvr(xcvr) to bind a transceiver to its socket by name match, then connect() + start() to begin the event loop thread. The event loop catches OSError (peer closed / connection refused) to cleanly unregister and close that socket without killing the thread, and catches any other Exception from downstream processing so a parse error in a listener can't silently kill the reader thread either. Accepts an optional on_disconnect(name) callback, invoked when a socket is lost — the GUI uses this to reflect a peer-initiated disconnect in ReceiverManager instead of leaving stale "connected" state.
  • SerialBackend — reads/writes a pyserial Serial port on a polling thread
  • FileBackend — replays one or more raw log files (as produced by FrameLogger) chunk-by-chunk on a thread per file; source_id_from_path extracts the receiver name from the YYYY_MM_DD_HH_MM_SS_<name>.log filename convention

NMEA parsing (nmea/):

  • NmeaPacketizer: buffers bytes and emits a MsgContainer per line (CR/LF delimited)
  • NmeaListener / AsciiListener: filters by regex on the raw bytes; subclasses override on_recv to parse CSV fields
  • Message classes (nmea/messages/): Gga, Gll, Gsa, Gsv, Rmc — each takes (name, tid) where tid is 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_hdr decode received frames
  • UbxPacketizer: buffers bytes, detects sync word 0xB5 0x62, reassembles complete frames by checking the length field and verifying checksum
  • UbxMsg (base class in ubx/messages/msg.py): holds mclass / mid; subclasses implement from_bytes(data) as a classmethod
  • UbxListener: wraps a UbxMsg, matches by class+id in is_msg, parses and prints in on_recv
  • UbxQuery (ubx/query.py): extends UbxListener with a semaphore-based synchronous poll() — 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 walk remain >= 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 raising struct.error on a short slice
  • rxm.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.pyReceiverManager(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_receiver build the backend + packetizer + listener chain for TCP/Serial/File sources. A peer-initiated disconnect (NetworkBackend's on_disconnect callback, fired from the backend thread) is marshaled to the GUI thread via an internal _connection_lost signal and handled by tearing the receiver down the same way an explicit disconnect does. ConnectionConfig + an LRU history persisted to ~/.nmea_client_gui.json round out connection setup.
  • listeners.pyNavSatQtListener (UBX NAV-SAT → SatelliteData list) and GsaQtListener (NMEA GSA → pDOP) bridge parsed messages to ReceiverManager callbacks.
  • connection_tab.pyConnectionTab + 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-thread QPainter rendering, no external plotting deps), satellite table, per-satellite trails, GNSS-source filter checkboxes.
  • plot_tab.py / plot_widget.py — 8 independent time-series plots (custom QPainter canvas, 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

  1. Create a subclass of UbxMsg with the correct mclass/mid and a from_bytes classmethod
  2. Instantiate UbxListener(YourMsg()) and register it on the UbxPacketizer

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.