From 61cba2c2f412944edf2ab6d432450043e0050727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Mon, 24 Aug 2026 19:18:33 +0200 Subject: [PATCH 1/2] feat(app): keep the SNR the repeater heard us at A trace reply carries, in its path bytes, the SNR at which each hop heard the transmission it forwarded -- the firmware writes (int8_t)(SNR*4) before retransmitting. For a trace we sent, the first of those is the node we addressed reading OUR ping: the reciprocal of every measurement this app makes, from the same position at the same moment. An asymmetry between the two is what an obstruction or a bad antenna looks like. We were decoding the packet and dropping the number. It is kept only on a reply we provoked, attached where #481 recognises our own tag: on an overheard trace those same bytes describe other nodes hearing each other. It gets its own field rather than being folded into snr, which means our radio's reading on every other row. Nothing renders it yet and nothing publishes it -- the payload and the ingestor schema have no field for it, and no reader needs one until something reads it. Co-Authored-By: Claude Opus 5 --- app/src/__tests__/capture.test.js | 24 +++++++++++++++++++++- app/src/__tests__/classify.test.js | 25 +++++++++++++++++++++- app/src/__tests__/tracetag.test.js | 33 +++++++++++++++++++++++++++++- app/src/app.js | 10 +++++++-- app/src/capture.js | 5 +++++ app/src/meshpacket.js | 18 ++++++++++++++++ 6 files changed, 110 insertions(+), 5 deletions(-) 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__/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 From 5ccfb66144fb633e31cfdc0b7d03eed750d10998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Sat, 29 Aug 2026 16:18:15 +0200 Subject: [PATCH 2/2] feat(app): show the reciprocal SNR on the trace reply's ticker row The field was written and then pruned unread: nothing rendered it, so a 7-day retention deleted every value it collected. The ticker's meta cell is where a reception explains itself, and on the one row that has it the reciprocal reading says more than the bare type label - so it takes that slot, and only that slot (text and channel name keep precedence). One decimal, the same format as the HUD's SNR readout. A 0 dB reading stays visible. Changelog entry in both copies. Verified: app 859 tests + build, web 504 tests, eslint clean. All three lineMeta mutations (branch removed, truthy guard, reordered above text) go red. Co-Authored-By: Claude Fable 5 --- app/changelog.json | 7 +++++++ app/src/__tests__/receptionlog.test.js | 28 +++++++++++++++++++++++++- app/src/receptionlog.js | 9 ++++++++- web/changelog.json | 7 +++++++ 4 files changed, 49 insertions(+), 2 deletions(-) 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__/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/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",