Files
meshcore-analyzer/docs
efitenandClaude Opus 5 9e13e0b05f feat(ingestor): full-packet RF observations from mobile clients (#1905)
## What

A CoreDrive RX drive already carries far more RF information than
reaches CoreScope, and it was being discarded twice: once in the mobile
app (every packet it could not attribute to a directly-heard node was
dropped before queueing) and once here (the ingestor decodes the
*complete* packet, then keeps only
`heard_key`/`snr`/`rssi`/`lat`/`lon`).

This captures what was being thrown away, at **zero extra airtime** —
nothing new is transmitted.

- **`transmissions.code1` / `code2`** — the transport codes were decoded
on every packet and used only to derive `scope_name`, then dropped.
Storing them turns "which repeater forwards which scope" from a re-parse
into a query.
- **An async backfill** re-parses the `raw_hex` already on disk, so
months of scope history become queryable with no new data collection.
- **`client_rx_observations`** — a new diagnostic table holding every
decodable packet a phone heard, with route type, transport codes, scope
name, path-hash size, the full forwarder chain and the forwarder.

## Why it is safe for existing deployments

Both halves are **opt-in and default off**
(`clientRxObservations.enabled`, and `fullRfLog` on the app side), so an
existing deployment sees no behaviour change and no volume change on
upgrade.

The coverage invariant is untouched: `client_receptions` keeps its rule
— 0-hop advert pubkey or FLOOD `path[last]`, ≥2-byte hash — and an
unattributable packet writes **zero** coverage rows. `deriveHeardKey`,
`buildClientReception` and `InsertClientReception` are unmodified except
for one guard described below.

## Performance justification (touches the ingest hot path)

- **Backfill:** keyset-paginated by `id` in 5000-row batches, a single
forward scan, `rows.Close()` before `Begin()` so it never deadlocks
against `SetMaxOpenConns(1)`, and commits per batch so live ingest
interleaves. Termination is driven by rows *scanned*, not rows decoded —
an earlier count-based loop would have stopped at the first batch
containing an undecodable row and then written its completion guard,
permanently stranding the rest.
- **Guard row is written if and only if the loop ran to genuine
exhaustion.** Every error path leaves it unwritten so the next startup
retries.
- **Per-packet cost:** one extra INSERT on the client topic when
enabled, gated behind an opt-in flag. No new work on the observer path.
- **New indexes** cover the prune (`rx_at`), the flood-grouping
(`pkt_hash, rx_at`), the per-repeater query (`forwarder, rx_at`) and the
scope query (`scope_name, rx_at`). Retention has its own shorter window
— this table is diagnostic, not archival.

## Two firmware-derived correctness points

- **`pkt_hash` is `ComputeContentHash()`**, byte-identical to
`transmissions.hash`, so dark-traffic queries are a plain equality join
rather than a translation layer.
- **TRACE packets are refused.** TRACE repurposes the header path bytes
as per-hop SNR values, so deriving a `heard_key` from them invents a
node that never existed. `packetpath.PathBytesAreHops` existed but was
never wired into the client path; it became reachable only because the
app half now publishes packets it previously dropped locally.

## Testing

Full ingestor suite green. Notable coverage: a FLOOD-routed TRACE writes
zero coverage rows and NULL `forwarder`; a `direction: "tx"` message
writes no observation; a DIRECT route never sets `forwarder`; two
forwarder copies of one flood remain two rows; the backfill's
multi-batch path is exercised with an undecodable row in the first page;
and a forced error asserts the migration guard stays unwritten.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:35:20 +02:00
..
2026-06-13 04:16:54 +00:00