diff --git a/CLAUDE.md b/CLAUDE.md index 60958b5..5273c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,25 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this project is -A Python client for u-blox GNSS receivers (NEO-F9P, ZED-X20P) connected over TCP/IP. It receives and parses both NMEA 0183 and UBX binary protocol messages, and can send UBX poll requests to the receivers. +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 is in `readme.md` (u-blox datasheets, integration manuals, interface specs, RTK/RTCM references). +Hardware reference documentation and CLI usage examples are in `readme.md` (u-blox datasheets, integration manuals, interface specs, RTK/RTCM references). ## Running ```bash -python network_backend.py +# 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 ``` -The entry point in `network_backend.py` connects to two GNSS receivers, wires up packetizers and message listeners, then polls UBX messages every second. +`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: ``` -NetworkBackend (TCP socket per receiver) - └─> Transceiver (one per receiver, named to match socket) +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) ``` @@ -30,11 +35,16 @@ NetworkBackend (TCP socket per receiver) **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 and dispatches +- `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`. -**NetworkBackend** (`network_backend.py`): manages non-blocking TCP sockets via `selectors`. Each connection has a name, address, associated `Transceiver`, and outgoing `Queue`. Call `register_xcvr(xcvr)` to bind a transceiver to its socket by name match, then `connect()` + `start()` to begin the event loop thread. +**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_.log` filename convention **NMEA parsing** (`nmea/`): - `NmeaPacketizer`: buffers bytes and emits a `MsgContainer` per line (CR/LF delimited) @@ -49,8 +59,24 @@ NetworkBackend (TCP socket per receiver) - `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) -- `rxm.py`: `RawX` (0x02/0x15 RXM-RAWX) +- `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