Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
[
{
"id": "2026-08-29-heard-us-snr",
"date": "2026-08-29",
"where": "app",
"title": "How well a node heard you",
"body": "Ping a node and the reply's row in the ticker shows the SNR it heard you at."
},
{
"id": "2026-08-26-app-design-pass",
"date": "2026-08-26",
Expand Down
24 changes: 23 additions & 1 deletion app/src/__tests__/capture.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('buildRecord', () => {
expect(rec).toEqual({
rx_at: '2026-06-29T10:00:00Z', rx_pubkey: 'aabbcc', raw: 'dead', snr: -3.5, rssi: -92, lat: 51, lon: 4, acc_m: 8,
sender_kind: 'channel_name', sender_id: 'Spammer', sender_label: 'Spammer', sender_role: null, channel_name: 'public',
is_direct: true, hops: 0, packet_type: 'GroupText',
is_direct: true, hops: 0, packet_type: 'GroupText', heard_us_snr: null,
})
expect('text' in rec).toBe(false)
})
Expand Down Expand Up @@ -108,3 +108,25 @@ describe('unattributable receptions end to end (#454)', () => {
expect(rec.is_direct).toBe(true)
})
})

// #482: a trace reply we provoked carries the SNR the target heard US at. It is
// not our radio's reading, so it gets its own field rather than being folded
// into `snr`, which means the opposite on every other row.
describe('buildRecord — the reciprocal SNR (#482)', () => {
const frame = { snr: -4.25, rssi: -97, raw: new Uint8Array([0x01]) }
const gps = { lat: 51, lon: 4, acc_m: 8 }

it('keeps it apart from our own SNR', () => {
const cls = { packetType: 'Trace', hops: 1, isDirect: false, heardUsSnr: -11.5,
sender: { kind: 'trace_reply', id: 'ab12', label: null, role: null }, channel: null, text: null }
const rec = buildRecord(frame, cls, gps, '2026-08-24T12:00:00Z')
expect(rec.heard_us_snr).toBe(-11.5)
expect(rec.snr).toBe(-4.25)
})

it('is null on every row that has no such reading', () => {
const cls = { packetType: 'Advert', hops: 0, isDirect: true,
sender: { kind: 'advert_pubkey', id: 'ab', label: null, role: null }, channel: null, text: null }
expect(buildRecord(frame, cls, gps, '2026-08-24T12:00:00Z').heard_us_snr).toBeNull()
})
})
25 changes: 24 additions & 1 deletion app/src/__tests__/classify.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { classifyReception, carriesSignedIdentity, stripIdentity, undecodableReception } from '../meshpacket.js'
import { classifyReception, carriesSignedIdentity, stripIdentity, undecodableReception, heardUsSnr } from '../meshpacket.js'
import { decodePacket } from '../decode.js'

const mk = (payloadType, decoded, pathLength = 0) => ({ payloadType, pathLength, payload: { decoded } })
Expand Down Expand Up @@ -148,3 +148,26 @@ describe('undecodableReception (#454 class 5)', () => {
expect(undecodableReception().packetType).not.toBe('RawCustom')
})
})

// #482: a TRACE packet's path bytes are the SNR each hop measured of the
// transmission it forwarded — firmware writes `(int8_t)(pkt->getSNR()*4)` before
// retransmitting (src/Mesh.cpp). For a trace we sent, the first of those is the
// target's reading of US: the reciprocal of every measurement this app makes.
describe('heardUsSnr (#482)', () => {
const trace = (snrValues) => ({ payloadType: 9, payload: { decoded: { traceTag: '00000001', snrValues } } })

it('reads the first hop, which is the node we addressed', () => {
expect(heardUsSnr(trace([-4, -11.5]))).toBe(-4)
})
it('decodes the firmware encoding, a signed byte of SNR times four', () => {
// 0xf0 = -16 as int8, /4 = -4 dB. Driven through the real decoder rather
// than a hand-built fixture, so this pins the path the app actually takes.
const hex = ['26', '01', 'f0', '01000000', '00000000', '00', 'ab'].join('')
expect(heardUsSnr(decodePacket(hex))).toBe(-4)
})
it('has nothing to say about a packet with no path SNRs', () => {
expect(heardUsSnr(trace([]))).toBeNull()
expect(heardUsSnr({ payloadType: 4, payload: { decoded: { publicKey: 'ab' } } })).toBeNull()
expect(heardUsSnr(null)).toBeNull()
})
})
28 changes: 27 additions & 1 deletion app/src/__tests__/receptionlog.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { rxView, rxActiveIndex, rxFade, rxLineHeight, senderText } from '../receptionlog.js'
import { rxView, rxActiveIndex, rxFade, rxLineHeight, senderText, lineMeta } from '../receptionlog.js'

const rec = (o) => ({ id: 1, rx_at: '2026-06-29T10:00:00Z', ...o })

Expand All @@ -26,6 +26,32 @@ describe('senderText — an id is never dressed as a name', () => {
})
})

// #482: a trace reply we provoked carries the SNR the node we pinged heard US
// at. The meta cell is where a reception explains itself (text > channel >
// type label), and for the one row that has it, the reciprocal reading says
// more than the bare word "Trace" — so it takes the type label's slot, and
// only that slot.
describe('lineMeta — the reciprocal SNR on a trace reply (#482)', () => {
it('shows what the node heard us at, in the HUD\'s SNR format', () => {
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: -11.5 })).toBe('heard us at -11.5 dB')
// The firmware unit is a quarter dB; one decimal matches #hud-snr.
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: -4.25 })).toBe('heard us at -4.3 dB')
})
it('shows a 0 dB reading rather than dropping it', () => {
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: 0 })).toBe('heard us at 0.0 dB')
})
it('leaves a trace nobody asked for as its type label', () => {
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: null })).toBe('Trace')
expect(lineMeta({ packet_type: 'Trace' })).toBe('Trace')
})
it('never outranks a decrypted text or a channel name', () => {
// No packet carries both today (a trace has no text); pinned so a field
// shuffle cannot silently demote the message text below a number.
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: -4, _text: 'hoi' })).toBe('“hoi”')
expect(lineMeta({ packet_type: 'Trace', heard_us_snr: -4, channel_name: 'public' })).toBe('public')
})
})

describe('rxView — source select, ascending by rx_at, recent cap', () => {
const filtered = [rec({ id: 1, rx_at: '2026-06-29T10:00:00Z' }), rec({ id: 2, rx_at: '2026-06-29T10:02:00Z' })]
const all = [...filtered, rec({ id: 3, rx_at: '2026-06-29T10:01:00Z' })]
Expand Down
33 changes: 32 additions & 1 deletion app/src/__tests__/tracetag.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { TRACE_TTL_MS, normalizeTag, rememberPing, prunePings, matchTraceTarget } from '../tracetag.js'
import { traceTagOf, classifyReception, hexToBytes } from '../meshpacket.js'
import { traceTagOf, classifyReception, hexToBytes, heardUsSnr } from '../meshpacket.js'
import { decodePacket } from '../decode.js'
import { buildRecord } from '../capture.js'

Expand Down Expand Up @@ -135,3 +135,34 @@ describe('a trace reply through the capture path (#481)', () => {
expect(matchTraceTarget(pending, traceTagOf(decoded), T0 + 500)).toBeNull()
})
})

// #482 rides on #481's match: the reciprocal reading is only meaningful for a
// reply we provoked, so it is attached where the tag is recognised and nowhere
// else. This is the same sequence app.js runs, with that step included.
describe('the reciprocal SNR travels with the attribution (#482)', () => {
const gps = { lat: 51.5, lon: 4.5, acc_m: 8 }
const hex = ['26', '01', 'f0', '11223344', '00000000', '00', 'ab'].join('')

it('records what the target heard us at, next to what we heard it at', () => {
const decoded = decodePacket(hex)
const pending = rememberPing([], 0x44332211, pk('ab12'), T0)
const target = matchTraceTarget(pending, traceTagOf(decoded), T0 + 500)
expect(target).toBe(pk('ab12'))

const cls = { ...classifyReception(decoded), heardUsSnr: heardUsSnr(decoded),
sender: { kind: 'trace_reply', id: target, label: null, role: null } }
const rec = buildRecord({ snr: -4.25, rssi: -97, raw: hexToBytes(hex) }, cls, gps, '2026-08-24T12:00:00Z')
expect(rec.heard_us_snr).toBe(-4) // theirs, from the path byte
expect(rec.snr).toBe(-4.25) // ours, from the 0x88 header
expect(rec.sender_id).toBe(pk('ab12'))
})

it('leaves the field null on a trace nobody asked for', () => {
const decoded = decodePacket(hex)
expect(matchTraceTarget([], traceTagOf(decoded), T0)).toBeNull()
const rec = buildRecord({ snr: -4.25, rssi: -97, raw: hexToBytes(hex) },
classifyReception(decoded), gps, '2026-08-24T12:00:00Z')
expect(rec.heard_us_snr).toBeNull()
expect(rec.sender_id).toBeNull()
})
})
10 changes: 8 additions & 2 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { WebBluetoothTransport } from './transport.js'
import { parseFrame, PUSH_CODE_LOG_RX_DATA } from './frames.js'
import { initDecoder, decodePacket, channelNameFor, bytesToHex, verifyAdvertSignature } from './decode.js'
import { classifyReception, carriesSignedIdentity, stripIdentity, undecodableReception, traceTagOf } from './meshpacket.js'
import { classifyReception, carriesSignedIdentity, stripIdentity, undecodableReception, traceTagOf, heardUsSnr } from './meshpacket.js'
import { rememberPing, matchTraceTarget } from './tracetag.js'
import { buildRecord, shouldCapture } from './capture.js'
import { Queue, RETENTION_MS, shouldContinueDraining, nextWatermark } from './queue.js'
Expand Down Expand Up @@ -691,7 +691,13 @@ async function processFrame(dv) {
// tag off, and its fields are placeholders rather than readings (#454).
if (decodedOk && cls.sender.id == null) {
const target = matchTraceTarget(state.tracePings, traceTagOf(decoded), Date.now())
if (target) cls = { ...cls, sender: { kind: 'trace_reply', id: target, label: null, role: null } }
// The same packet also carries how well that node heard us (#482) — its own
// radio's reading of our ping, appended to the path before it forwarded.
// Only kept for a reply we provoked: on an overheard trace those bytes
// describe other nodes hearing each other.
if (target) {
cls = { ...cls, sender: { kind: 'trace_reply', id: target, label: null, role: null }, heardUsSnr: heardUsSnr(decoded) }
}
}
const fix = state.gps.latest()
if (!shouldCapture(cls, fix)) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,10 @@ export function buildRecord(frame, cls, gps, nowIso, rxPubkey = '') {
is_direct: cls.isDirect,
hops: cls.hops,
packet_type: cls.packetType,
// How well the node we pinged heard US (#482) — read off the trace reply's
// path bytes and attached by the caller, which is the only place that knows
// the reply is one we provoked. Null everywhere else, including on an
// overheard trace, whose path SNRs describe other nodes hearing each other.
heard_us_snr: cls.heardUsSnr ?? null,
}
}
18 changes: 18 additions & 0 deletions app/src/meshpacket.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ export function undecodableReception() {
return { packetType: 'Unknown', hops: 0, isDirect: false, sender: NO_SENDER(), channel: null, text: null }
}

// heardUsSnr reads, off a decoded TRACE packet, the SNR at which the first hop
// heard the transmission it forwarded. For a trace WE sent, that first hop is
// the node we addressed and the transmission is ours — so this is the reciprocal
// of every other measurement the app makes: how well that node hears us, from
// the position we were standing in, at the same moment (#482).
//
// The firmware writes it as a signed byte of SNR x 4 before retransmitting
// (src/Mesh.cpp); the decoder converts it back to dB.
//
// It is only meaningful on a trace we provoked. On an overheard one the value
// describes how some other node heard some other transmitter, which is why the
// caller attaches it alongside the tag match and not from classifyReception.
export function heardUsSnr(decoded) {
const d = decoded && decoded.payload && decoded.payload.decoded
const v = d && Array.isArray(d.snrValues) ? d.snrValues[0] : undefined
return typeof v === 'number' && Number.isFinite(v) ? v : null
}

export function classifyReception(decoded, channelNameFor = () => null) {
const pt = decoded.payloadType
const hops = decoded.pathLength || 0
Expand Down
9 changes: 8 additions & 1 deletion app/src/receptionlog.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,16 @@ const CAP = 200 // recent-window cap
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim()

function lineMeta(r) {
// The meta cell is where a reception explains itself: a decrypted text first,
// then a channel name, then the packet-type label. A trace reply we provoked
// also carries the SNR the node we pinged heard US at (#482) — the reciprocal
// of every other number in the log, and the reason the ping was sent — so on
// that one row the reading takes the type label's slot. One decimal, the same
// format as #hud-snr. `!= null` keeps a real 0 dB reading visible.
export function lineMeta(r) {
if (r._text) return '“' + r._text + '”'
if (r.channel_name) return r.channel_name
if (r.heard_us_snr != null) return 'heard us at ' + r.heard_us_snr.toFixed(1) + ' dB'
return packetTypeLabel(r.packet_type) || ''
}

Expand Down
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
[
{
"id": "2026-08-29-heard-us-snr",
"date": "2026-08-29",
"where": "app",
"title": "How well a node heard you",
"body": "Ping a node and the reply's row in the ticker shows the SNR it heard you at."
},
{
"id": "2026-08-26-app-design-pass",
"date": "2026-08-26",
Expand Down
Loading