← Back to Notebook
FAMILY PROJECT

Stitching Together What Your Radios Each Half-Heard

Personal build · a family LoRa mesh, and the dedup layer it needed

I have eight Meshtastic radios scattered around the family (the "Schultznom" mesh — two channels, one public, one private). Each one is a cheap LoRa node that can hear text messages, position beacons, and telemetry from any other node in range, no cell service or internet involved. That part's off-the-shelf — Meshtastic does it out of the box.

The problem I actually wanted to solve wasn't "send a text over radio." It was: I own multiple radios, and no single one of them hears everything. Each device caches maybe 30 packets and only knows what passed within its own range and its own uptime window. Walk away from one radio carrying another, and you've got two incomplete, overlapping, un-deduplicated views of the same conversation. Nobody's chat app treats "I own five receivers for one conversation" as the actual shape of the problem — most multi-radio tools I looked at (MeshMonitor was the closest prior art) aggregate live streams from several sources but deliberately don't dedup across them, and none of them drain a radio's on-device cache as a backfill source. So I built the stitching layer myself: MeshChat.

The pipeline is boring by design, which is the point:

  1. meshlog.py sits on a USB-connected radio and logs every packet it hears to a JSONL file — one JSON object per line, sender, SNR/RSSI, hop count, decoded text/position/telemetry, plus a GPS snapshot of its own position every 60 seconds.
  2. meshchat.py import reads however many of those JSONL files exist — one per radio — into a single SQLite store.
  3. meshchat.py log <channel> renders the merged, time-ordered thread.

The interesting part: the join key

The join key across radios is (sender_id, packet_id) — Meshtastic text packets don't carry a send timestamp, so the packet ID plus sender is the only thing that lets you recognize "radio A and radio B both heard the same message" rather than two different messages. Every insert is INSERT OR IGNORE on that key for the message itself, but receipts are a separate table keyed on (sender_id, packet_id, radio_id) — so if three radios all heard the same text, you get one message row and three receipt rows, not three copies of the message. That's a deliberate design rule in the plan doc: "never collapse receipts" — the same table that prevents duplicate messages also happens to be an RF-mapping dataset (which radio heard what, how strong, how many hops), so the dedup layer and the signal-map data are the same rows for free.

The output makes the multi-witness part visible instead of hiding it. A stitched log line looks like:

2026-07-10 20:49:40  [Schultznom] Chewbacca: I see you!   ⟨LEIA -51dBm direct⟩

The trailing ⟨...⟩ is a witness badge — which radio heard it, signal strength, hop count (direct = 0 hops, via N hop(s) otherwise). A message heard by more than one radio gets one badge per witness, so the log itself carries the RF picture, not just the conversation.

One other detail worth calling out: import is idempotent by design, not by accident. Each JSONL file is append-only, and meshchat.py remembers a per-file line-count cursor in an imports table — re-running import on a file you've already consumed is a no-op (0 new rows), because it just resumes from the cursor. --full throws the cursor away and reimports the whole file from scratch, which is safe only because every insert underneath is INSERT OR IGNORE (set union, not overwrite) — replaying old lines can't duplicate or corrupt anything already in the store. That's a small thing, but it's the kind of small thing that makes the tool safe to run carelessly, which matters a lot more than it sounds like for something you're going to invoke from a cron job eventually.

What's real right now

Per the test suite (8/8 passing) and the project's done log: the JSONL logger, the SQLite import/dedup, the stitched log CLI with witness badges, a status command for totals, and a channel-safe send path (it defaults to the private family channel and refuses to post to the public channel unless you pass an explicit override — added after an early test message, "Who likes Pokemon??," leaked to the public mesh by accident).

What's not real yet

Everything above assumes radios plugged into USB. The actual target is a BLE polling rotation — connect to each radio over Bluetooth in turn, drain its cache, disconnect, move to the next one, so you don't need a laptop physically wired to every device in the house. That code (meshpoll.py) exists and is transport-abstracted (BLE/serial/TCP behind one interface) with its own mock-tested suite, including an "adaptive dwell" design — hold the connection open only as long as packets are still arriving, instead of guessing a fixed timeout — but as of this writing it has not been run against a real BLE radio. That's the next physical-world step, and it's gated on actually pairing a device, not on more code.

I don't have a public demo of this — it's a family radio network, not a product. But the actual technical problem (reconcile N partial, overlapping, cache-limited witnesses of the same event stream into one deduplicated timeline) is a small, clean instance of a problem that shows up anywhere you have multiple unreliable observers of the same ground truth, and solving it for eight LoRa radios in the backyard was a good excuse to think about it properly.

Worth flagging
  • This is a personal/family project, not a product — no roadmap toward shipping it to anyone else.
  • BLE polling (meshpoll.py) is built and mock-tested but has not yet been run against real hardware — that's the next concrete step, not a "coming soon" claim.