Covers link inference from traceroute, zero-hop MQTT, NeighborInfo, and relay_node fields. Includes multi-gateway correlation, hop bounding, SNR-colored polyline rendering on the map, and node detail connections. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
15 KiB
Node Topology Tracking Design
Date: 2026-03-15 Status: Approved
Overview
Track and visualize RF link topology between Meshtastic mesh nodes. Most needed fields already exist; two fields (rx_snr, rx_rssi) require a small backend addition (proto field + decoder extraction only).
The result is a topology graph of LinkObservation edges rendered as colored polylines on the existing Google Maps view, with additional connection data surfaced on the node detail page.
Required Backend Change (small)
rx_snr and rx_rssi are present in MeshPacket in mesh.proto (fields 8 and 12) but are not currently extracted into the meshstream Data message. Add them:
proto/meshstream/meshstream.proto — add inside Data:
float rx_snr = 62; // SNR at receiving gateway (dB)
int32 rx_rssi = 63; // RSSI at receiving gateway (dBm)
decoder/decoder.go — add two lines in DecodeMessage alongside the existing hop field extractions:
data.RxSnr = packet.GetRxSnr()
data.RxRssi = packet.GetRxRssi()
Run make gen-proto to regenerate Go and TypeScript types. This is the only backend change — no logic changes, no new endpoints.
Data Sources
Four sources contribute to the topology graph, ranked by confidence:
1. Traceroute Responses (highest confidence)
Condition: portNum === TRACEROUTE_APP and wantResponse === false
Traceroute requests (wantResponse === true) are silently skipped — they carry a partially-built path and no return data. Only the completed reply (wantResponse === false) has a full round-trip path.
Deduplication: Skip if packetKey (= !${from.toString(16)}_${id}) was already processed for traceroute. Track a separate processedTraceroutes set in topology state.
The reply packet: from = destination node, to = origin node.
Forward path:
path = [data.to, ...routeDiscovery.route, data.from]
// N = route.length + 2 nodes, N-1 edges
// snrTowards[i] / 4 = SNR in dB at path[i+1] receiving from path[i]
// iterate i = 0 .. N-2, capped at Math.min(snrTowards.length, N-1)
Return path:
returnPath = [data.from, ...routeDiscovery.routeBack, data.to]
// M = routeBack.length + 2 nodes, M-1 edges
// snrBack[i] / 4 = SNR in dB at returnPath[i+1] receiving from returnPath[i]
// iterate i = 0 .. M-2, capped at Math.min(snrBack.length, M-1)
Each consecutive pair is an individual directed edge. Both directions for the same physical link are stored independently as snrAtoB / snrBtoA.
If routeDiscovery is undefined, or route is empty/undefined, attempt to extract only the direct data.to ↔ data.from edge with no SNR data.
2. Zero-Hop MQTT Observations (continuous, passive)
Condition: hopStart === hopLimit and gatewayId starts with !
Deduplication: None — intentionally process all copies. Different gateways deliver different rxSnr/rxRssi values for the same transmission. Each copy yields an independent edge between from and that specific gateway.
nodeA = packet.from
nodeB = parseInt(gatewayId.substring(1), 16)
snrAtoB = data.rxSnr // SNR at gateway receiving from nodeA (dB)
rssiAtoB = data.rxRssi // RSSI at gateway receiving from nodeA (dBm)
Note on rxSnr === 0 and rxRssi === 0: Proto3 encodes unset fields as 0. A true 0 dB SNR reading is rare but valid. This design accepts the trade-off: store the SNR/RSSI value unconditionally and let the display treat 0 as a valid weak reading (colored red). Do not skip zero-valued SNR.
viaMqtt semantics: data.viaMqtt (from MeshPacket) is set true by Meshtastic firmware when the packet entered the mesh via an MQTT downlink (i.e., it was injected from the internet into the mesh rather than originating from a local RF transmission). A zero-hop observation with viaMqtt === true means the link between from and the gateway may not be a pure RF hop. Set edge.viaMqtt = true when data.viaMqtt === true. This is stored on the edge as a display hint — de-emphasize MQTT-bridged links visually.
Non-! prefixed gateway IDs (e.g., +phonenumber): Skip — do not attempt to parse as a node ID.
3. NeighborInfo Packets
Condition: portNum === NEIGHBORINFO_APP
Deduplication: Use key !${neighborInfo.nodeId.toString(16)}_${data.id} (broadcaster ID + packet ID). Skip if already in processedTraceroutes.
Use neighborInfo.nodeId (not data.from) as the broadcaster — if relayed, data.from is the relay, not the observer. For each neighbor:
broadcaster = neighborInfo.nodeId
neighbor = neighbor.nodeId
SNR measured = broadcaster receiving from neighbor (no /4 scaling)
Canonical assignment: nodeA = min(broadcaster, neighbor), nodeB = max(broadcaster, neighbor)
if broadcaster === nodeA: this is snrAtoB (nodeA received from nodeB)
if broadcaster === nodeB: this is snrBtoA (nodeB received from nodeA)
4. relay_node Inferred Links
Condition: hopStart - hopLimit === 1 and relayNode !== 0 and gatewayId starts with !
relayNode is packet.data.relayNode (already decoded into Data as relayNode: number). It contains the last byte (8 bits) of the relay node's full ID. The node store to search is passed via the action payload as nodeIds: number[] (computed in __root.tsx as Object.keys(store.getState().aggregator.nodes).map(Number) before dispatch). Search for nodes where (nodeId & 0xFF) === relayNode. If exactly one candidate: record two edges with no SNR, source relay_inferred:
from → relay (from is sender, relay is candidate node)
relay → gateway (relay to numeric(gatewayId))
Zero or multiple candidates: skip silently.
Clever Tricks
Multi-Gateway Same-Packet Correlation
processTopologyPacket receives every raw SSE packet including multiple copies of the same (from, id) from different gateways. Zero-hop processing (source 2) intentionally does not deduplicate, so each gateway arrival naturally yields an independent edge. No special detection is needed.
Hop Count Bounding
Add hopsFromGateway?: number to NodeData in aggregatorSlice.ts. Set it in the existing processPacket function (not in topologySlice — avoids cross-slice writes):
const hops = (data.hopStart ?? 0) - (data.hopLimit ?? 0);
if (hops >= 0) node.hopsFromGateway = hops;
Data Model
LinkObservation
interface LinkObservation {
key: string; // canonical: `${Math.min(nodeA,nodeB)}-${Math.max(nodeA,nodeB)}`
nodeA: number; // lower numeric node ID
nodeB: number; // higher numeric node ID
snrAtoB?: number; // SNR in dB at nodeB receiving from nodeA
snrBtoA?: number; // SNR in dB at nodeA receiving from nodeB
rssiAtoB?: number; // RSSI in dBm at nodeB receiving from nodeA (gateway-measured only)
sourceAtoB: LinkSource; // confidence for snrAtoB direction; 'unknown' if not yet observed
sourceBtoA: LinkSource; // confidence for snrBtoA direction; 'unknown' if not yet observed
viaMqtt?: boolean; // true if any observation for this edge crossed an internet bridge
lastSeen: number; // unix timestamp
hopCount?: number; // total path length (traceroute only): path.length = route.length + 2
}
type LinkSource = 'traceroute' | 'neighbor_info' | 'zero_hop' | 'relay_inferred' | 'unknown';
Note on RSSI asymmetry: Only rssiAtoB is tracked (gateway-measured RSSI on the receiving end). There is no rssiBtoA because nodes do not report their received RSSI upstream through MQTT. This is intentional asymmetry.
New-edge initialization: When creating a new LinkObservation, set both sourceAtoB and sourceBtoA to 'unknown'. Then apply the first observation's data to the appropriate direction.
Merge Strategy
For each incoming observation, determine which direction it provides data for (AtoB or BtoA based on which node was the receiver). Apply per-direction:
- Always update
lastSeento the newer of current and incoming timestamp - If
viaMqttis true on the new observation, setviaMqtt: trueon the edge (never cleared) - For the observed direction:
- If current source is
'unknown': set SNR/RSSI and source unconditionally - If new source confidence is strictly higher: overwrite SNR/RSSI and source
- If new source confidence is equal or lower: update
lastSeenonly
- If current source is
- Source confidence order:
traceroute > neighbor_info > zero_hop > relay_inferred > unknown
Redux Reducer Purity
Redux reducers must not call Date.now(). The timestamp must come from the action payload. Use packet.data.rxTime if available; otherwise compute Math.floor(Date.now() / 1000) in the caller (__root.tsx) before dispatch.
Staleness / TTL
At the start of each processTopologyPacket call, prune all entries where actionTimestamp - edge.lastSeen > 86400. Apply before processing the new packet.
Also prune processedTraceroutes entries older than 24h to prevent unbounded growth.
Maximum Edge Count
Cap at 2000 edges. After TTL pruning and after inserting/updating the new edge: if Object.keys(state.links).length > 2000, sort by lastSeen ascending and remove the oldest entries until count is ≤ 2000. The just-inserted edge is retained unless it itself is the oldest (extremely unlikely given it was just seen).
Architecture
New: topologySlice.ts
Location: web/src/store/slices/topologySlice.ts
interface TopologyState {
links: Record<string, LinkObservation>;
processedTraceroutes: Record<string, number>; // packetKey → timestamp, for dedup
}
Reducers:
processTopologyPacket(state, action: PayloadAction<{ packet: Packet; timestamp: number; nodeIds: number[] }>)nodeIdsisObject.keys(store.getState().aggregator.nodes).map(Number), passed by caller before dispatch
- Prune stale edges and stale processedTraceroutes entries
- Extract zero-hop observations (no dedup)
- For traceroute: skip if packetKey in processedTraceroutes; otherwise process and set
hopCount = route.length + 2on each extracted edge - For neighborInfo: skip if dedup key in processedTraceroutes; otherwise process
- relay_node inference using
nodeIdsfrom payload - Enforce 2000-edge cap
clearTopology()— reset to empty state
Changes to aggregatorSlice.ts
Add hopsFromGateway?: number to NodeData interface. In processPacket, after node initialization:
const hops = (data.hopStart ?? 0) - (data.hopLimit ?? 0);
if (hops >= 0) node.hopsFromGateway = hops;
Integration in __root.tsx
// Existing
dispatch(processNewPacket(packet));
dispatch(addPacket(packet));
// New
const timestamp = packet.data.rxTime || Math.floor(Date.now() / 1000);
const nodeIds = Object.keys(store.getState().aggregator.nodes).map(Number);
dispatch(processTopologyPacket({ packet, timestamp, nodeIds }));
Also dispatch clearTopology() alongside clearAggregatedData().
store.ts
Add topology: topologyReducer to the root reducer.
NetworkMap.tsx Rendering
- Add
showLinks: booleanlocal state (defaulttrue). This is per-instance; eachNetworkMapmanages its own toggle independently. - Read
linksfromuseAppSelector((state) => state.topology) polylinesRef: React.MutableRefObject<Record<string, google.maps.Polyline>>- Call
updateLinks(links, nodesWithPosition, showLinks)afterupdateNodeMarkers
updateLinks logic:
- Build a position lookup:
Map<number, {lat, lng}>fromnodesWithPosition - For each
LinkObservationinlinks:- If either node has no position: skip rendering (keep polyline hidden if it existed)
- Determine display SNR:
snrAtoB ?? snrBtoA - Color:
>= 5 dB→#22c55e(green)0–5 dB→#eab308(yellow)< 0 dB→#ef4444(red)- no SNR data →
#6b7280(gray)
- Opacity: 0.7 normally; 0.4 if
viaMqtt === true - Create or update
google.maps.Polyline: strokeWeight 2, strokeColor per above, strokeOpacity per above - If
showLinks === false: set polylinevisible: false
- Remove polylines for keys no longer in
state.topology.links - Add to unmount cleanup: iterate
polylinesRef.currentand call.setMap(null)
Toggle button: Add "Links" Button to the map legend row alongside the existing Auto-zoom button.
NodeDetail.tsx Connections Section
Add a "Connections" section below existing node detail content:
- Read
linksfromuseAppSelector((state) => state.topology) - Read
nodesfromuseAppSelector((state) => state.aggregator.nodes)for name lookup - Filter: edges where
link.nodeA === nodeId || link.nodeB === nodeId - For each match:
- Determine neighbor ID: the other end of the edge
- Display: neighbor name (
nodes[neighbor]?.shortName ?? nodes[neighbor]?.longName ?? '!' + neighbor.toString(16)) - SNR:
→ X dB(snrAtoB, node→neighbor direction) and← Y dB(snrBtoA) if available - Source: highest confidence of
sourceAtoB/sourceBtoA(use the confidence ordering), displayed as a badge:traceroute→ bluezero_hop→ greenneighbor_info→ yellowrelay_inferred→ gray
- Last seen: relative time (existing
formatLastSeenpattern) - Via MQTT badge if
viaMqtt === true
- If no connections: show "No connections observed" in muted text
Error Handling
routeDiscoveryundefined: Guard withif (!data.routeDiscovery) return; skip traceroute processingrouteempty/undefined: Extract onlydata.to ↔ data.fromdirect edge with no SNR- SNR array length mismatch:
cap = Math.min(snrTowards.length, path.length - 1); never access out of bounds relay_nodeambiguity: Zero or multiple 8-bit suffix matches → skip silently- Non-
!prefixed gateway: Skip zero-hop and relay_inferred processing for that packet hopsFromGatewaynegative: Can occur if firmware sends inconsistent values; clamp to 0
Files Changed
| File | Change |
|---|---|
proto/meshstream/meshstream.proto |
Add rx_snr and rx_rssi fields |
decoder/decoder.go |
Extract RxSnr and RxRssi |
web/src/store/slices/topologySlice.ts |
New file |
web/src/store/store.ts |
Add topology reducer |
web/src/routes/__root.tsx |
Dispatch processTopologyPacket + clearTopology |
web/src/store/slices/aggregatorSlice.ts |
Add hopsFromGateway to NodeData, set in processPacket |
web/src/components/dashboard/NetworkMap.tsx |
Polyline rendering + toggle |
web/src/components/dashboard/NodeDetail.tsx |
Connections section |
Out of Scope
- Persisting topology across page refreshes (all state is in-memory, consistent with existing approach)
- Sending traceroute requests from the web UI (read-only observation)
- Rendering edges for nodes without position data (silently skipped)