diff --git a/app/changelog.json b/app/changelog.json index 397984ab..5e75a1c2 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -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", diff --git a/app/src/__tests__/capture.test.js b/app/src/__tests__/capture.test.js index eff00b96..c7c78bec 100644 --- a/app/src/__tests__/capture.test.js +++ b/app/src/__tests__/capture.test.js @@ -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) }) @@ -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() + }) +}) diff --git a/app/src/__tests__/classify.test.js b/app/src/__tests__/classify.test.js index 2b748f5c..0aa12b50 100644 --- a/app/src/__tests__/classify.test.js +++ b/app/src/__tests__/classify.test.js @@ -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 } }) @@ -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() + }) +}) diff --git a/app/src/__tests__/receptionlog.test.js b/app/src/__tests__/receptionlog.test.js index 42fd1da1..0eb00620 100644 --- a/app/src/__tests__/receptionlog.test.js +++ b/app/src/__tests__/receptionlog.test.js @@ -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 }) @@ -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' })] diff --git a/app/src/__tests__/tracetag.test.js b/app/src/__tests__/tracetag.test.js index a1bd8586..8fc21702 100644 --- a/app/src/__tests__/tracetag.test.js +++ b/app/src/__tests__/tracetag.test.js @@ -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' @@ -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() + }) +}) diff --git a/app/src/app.js b/app/src/app.js index 8e605197..3a90bbe1 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -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' @@ -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)) { diff --git a/app/src/capture.js b/app/src/capture.js index 9a254b09..c3330d40 100644 --- a/app/src/capture.js +++ b/app/src/capture.js @@ -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, } } diff --git a/app/src/meshpacket.js b/app/src/meshpacket.js index 7dd6ae3e..4ae6e74a 100644 --- a/app/src/meshpacket.js +++ b/app/src/meshpacket.js @@ -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 diff --git a/app/src/receptionlog.js b/app/src/receptionlog.js index b32fb901..ac899b07 100644 --- a/app/src/receptionlog.js +++ b/app/src/receptionlog.js @@ -65,9 +65,16 @@ const CAP = 200 // recent-window cap const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) || '' } diff --git a/web/changelog.json b/web/changelog.json index 397984ab..5e75a1c2 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -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",