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
89 lines
7.9 KiB
Markdown
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`.
|