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

89 lines
7.9 KiB
Markdown

# 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
```bash
# 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.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_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.py``NavSatQtListener` (UBX NAV-SAT → `SatelliteData` list) and `GsaQtListener` (NMEA GSA → pDOP) bridge parsed messages to `ReceiverManager` callbacks.
- `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-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`.