diff --git a/public/packets.js b/public/packets.js index 90feb101..a8477a97 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2865,6 +2865,33 @@ if (scrollContainer) scrollContainer.scrollTop = savedScrollTop; } + // #1868 — CONTROL DISCOVER_REQ/RESP node-type + SNR display helpers. + // node_type (RESP low nibble, single value) and filter (REQ byte, bitmask + // of these SAME type values -- firmware checks `filter & (1 << ADV_TYPE_x)`) + // share the ADV_TYPE_* enum already used for ADVERT role labels + // (cmd/ingestor/decoder.go's advertRole(), firmware/src/helpers/ + // AdvertDataHelpers.h:7-12): 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR. + var CTRL_TYPE_LABELS = { 0: 'None', 1: 'Companion', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor' }; + function ctrlTypeLabel(n) { + return CTRL_TYPE_LABELS[n] != null ? CTRL_TYPE_LABELS[n] : ('Unknown(' + n + ')'); + } + function ctrlFilterLabels(filter) { + var labels = []; + for (var bit = 0; bit <= 4; bit++) { + if (filter & (1 << bit)) labels.push(ctrlTypeLabel(bit)); + } + return labels; + } + // Firmware sends SNR as a wire-encoded int8 (value * 4); divide by 4.0 for + // real dB, same conversion already applied to TRACE's snrValues in both + // decoders (cmd/ingestor/decoder.go:1016, cmd/server/decoder.go:619) -- + // CONTROL's SNR just hasn't had the same conversion applied yet, and doing + // it here (display-only) avoids any ambiguity with already-stored raw + // values from packets ingested before this fix. + function ctrlSnrDb(raw) { + return (Number(raw) / 4.0).toFixed(2); + } + function getDetailPreview(decoded) { if (!decoded) return ''; // Channel messages (GRP_TXT) — show channel name and message text @@ -2941,7 +2968,8 @@ const parts = []; if (subtype === 'DISCOVER_REQ') { if (decoded.ctrlFilter != null) { - parts.push(`filter=0x${Number(decoded.ctrlFilter).toString(16).padStart(2, '0')}`); + const labels = ctrlFilterLabels(Number(decoded.ctrlFilter)); + parts.push(`filter=${labels.length ? labels.join('+') : '0x' + Number(decoded.ctrlFilter).toString(16).padStart(2, '0')}`); } if (decoded.ctrlTag != null) { parts.push(`tag=0x${(Number(decoded.ctrlTag) >>> 0).toString(16).padStart(8, '0')}`); @@ -2951,16 +2979,21 @@ } } else if (subtype === 'DISCOVER_RESP') { if (decoded.ctrlNodeType != null) { - parts.push(`type=${Number(decoded.ctrlNodeType)}`); + parts.push(`type=${ctrlTypeLabel(Number(decoded.ctrlNodeType))}`); } if (decoded.ctrlSNR != null) { - parts.push(`snr=${Number(decoded.ctrlSNR)}`); + parts.push(`snr=${ctrlSnrDb(decoded.ctrlSNR)}dB`); } if (decoded.ctrlTag != null) { parts.push(`tag=0x${(Number(decoded.ctrlTag) >>> 0).toString(16).padStart(8, '0')}`); } if (decoded.ctrlPubKey) { - parts.push(`pubkey=${escapeHtml(decoded.ctrlPubKey)}`); + // Row preview is synchronous/rendered per-row -- no live node + // lookup here (that would mean one API call per visible CONTROL + // row). Truncated to 8 hex chars, matching the same fallback + // used elsewhere in this file (e.g. srcLabel's pubKey.slice(0,8) + // in renderDetail) for an unresolved node identifier. + parts.push(`pubkey=${escapeHtml(decoded.ctrlPubKey.slice(0, 8))}…`); } } else if (decoded.ctrlFlags) { parts.push(`flags=0x${escapeHtml(decoded.ctrlFlags)}`); @@ -3134,6 +3167,17 @@ } catch {} } + // #1868 — CONTROL DISCOVER_RESP carries a responder pubkey (full 32B or + // 8B prefix) with no name attached. /api/nodes/{pubkey} already handles + // prefix resolution server-side (issue #772's short-URL fallback), so + // this works for both lengths the same way. Falls back to null (plain + // truncated hex in buildFieldTable) when unresolved/unknown/blacklisted. + let ctrlPubKeyNode = null; + if (decoded.type === 'CONTROL' && decoded.ctrlPubKey) { + const nd = await api(`/nodes/${decoded.ctrlPubKey}`, { ttl: 30000 }).catch(() => null); + if (nd?.node?.public_key) ctrlPubKeyNode = nd.node; + } + // Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver if (pathHops.length) { try { @@ -3327,7 +3371,7 @@ ${hasRawHex ? `
${buildHexLegend(ranges)}
${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}
` : ''} - ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} + ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges, ctrlPubKeyNode) : buildDecodedTable(decoded)} ` : ''} ${observations.length > 1 ? ` @@ -3508,7 +3552,7 @@ return rows ? `${rows}
` : ''; } - function buildFieldTable(pkt, decoded, pathHops, ranges) { + function buildFieldTable(pkt, decoded, pathHops, ranges, ctrlPubKeyNode) { const buf = pkt.raw_hex || ''; const size = Math.floor(buf.length / 2); let rows = ''; @@ -3607,6 +3651,41 @@ rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', ''); rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', ''); rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); + } else if (decoded.type === 'CONTROL') { + // #1868 — CONTROL DISCOVER_REQ/RESP field breakdown, matching decoder + // layout in cmd/ingestor/decoder.go decodeControl(). Body fields are + // length-gated there too, so each row is only added when present. + const subtype = decoded.ctrlSubtype || 'CONTROL'; + rows += fieldRow(off, 'Subtype', escapeHtml(subtype), decoded.ctrlFlags ? 'byte0 high nibble, flags=0x' + escapeHtml(decoded.ctrlFlags) : ''); + if (subtype === 'DISCOVER_REQ') { + if (decoded.ctrlFilter != null) { + const labels = ctrlFilterLabels(Number(decoded.ctrlFilter)); + rows += fieldRow(off + 1, 'Filter (1B)', '0x' + Number(decoded.ctrlFilter).toString(16).padStart(2, '0'), labels.length ? 'Requesting: ' + labels.join(', ') : 'No types requested'); + } + if (decoded.ctrlTag != null) { + rows += fieldRow(off + 2, 'Tag (4B)', '0x' + (Number(decoded.ctrlTag) >>> 0).toString(16).toUpperCase().padStart(8, '0'), ''); + } + if (decoded.ctrlSince != null) { + rows += fieldRow(off + 6, 'Since (4B)', String(Number(decoded.ctrlSince) >>> 0), 'Unix epoch'); + } + } else if (subtype === 'DISCOVER_RESP') { + if (decoded.ctrlNodeType != null) { + rows += fieldRow(off, 'Node Type', escapeHtml(ctrlTypeLabel(Number(decoded.ctrlNodeType))), 'byte0 low nibble'); + } + if (decoded.ctrlSNR != null) { + rows += fieldRow(off + 1, 'SNR (1B)', ctrlSnrDb(decoded.ctrlSNR) + ' dB', 'wire value ' + decoded.ctrlSNR + ' ÷ 4.0'); + } + if (decoded.ctrlTag != null) { + rows += fieldRow(off + 2, 'Tag (4B)', '0x' + (Number(decoded.ctrlTag) >>> 0).toString(16).toUpperCase().padStart(8, '0'), ''); + } + if (decoded.ctrlPubKey) { + const pkLen = decoded.ctrlPubKey.length === 64 ? '32B' : '8B prefix'; + const pkValue = ctrlPubKeyNode + ? `${escapeHtml(ctrlPubKeyNode.name || ctrlPubKeyNode.public_key.slice(0, 8) + '…')}` + : escapeHtml(truncate(decoded.ctrlPubKey, 24)); + rows += fieldRow(off + 6, 'Pubkey (' + pkLen + ')', pkValue, ctrlPubKeyNode ? '' : 'Unknown node'); + } + } } else { rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } diff --git a/test-packets.js b/test-packets.js index 72d782c9..c37ac14a 100644 --- a/test-packets.js +++ b/test-packets.js @@ -517,6 +517,19 @@ console.log('\n=== packets.js: getDetailPreview ==='); assert(result.includes('tag'), 'should render tag field'); }); + // #1868 — filter is a bitmask (ADV_TYPE_* bit-per-type, per firmware's + // `filter & (1 << ADV_TYPE_x)`); bit 2 = ADV_TYPE_REPEATER, so filter=4 + // (1<<2) must render as the human-readable type name, not raw hex. + test('getDetailPreview renders CONTROL DISCOVER_REQ filter as type name(s), not raw hex', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_REQ', + ctrlFilter: 4, // 1 << 2 = ADV_TYPE_REPEATER + }); + assert(result.includes('Repeater'), 'should show "Repeater" for filter bit 2, got: ' + result); + assert(!/filter=0x/.test(result), 'should not fall back to raw hex when bits are known, got: ' + result); + }); + test('getDetailPreview handles CONTROL DISCOVER_RESP', () => { const result = api.getDetailPreview({ type: 'CONTROL', @@ -528,7 +541,34 @@ console.log('\n=== packets.js: getDetailPreview ==='); }); assert(result.includes('DISCOVER_RESP'), 'should label subtype'); assert(result.includes('snr') || result.includes('SNR'), 'should render snr'); - assert(result.includes('0001020304050607'), 'should render pubkey hex'); + // #1868: pubkey truncated to first 8 hex chars for the per-row preview + // (no live node lookup per row -- see the async detail-panel resolution + // instead), and full raw hex must NOT leak into the row. + assert(result.includes('00010203'), 'should render truncated pubkey prefix, got: ' + result); + assert(!result.includes('0001020304050607'), 'should NOT render the full raw pubkey in the row preview, got: ' + result); + }); + + // #1868 — node_type (ADV_TYPE_REPEATER=2) must render as "Repeater", not + // the raw number. + test('getDetailPreview renders CONTROL DISCOVER_RESP node type as a name, not a raw number', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_RESP', + ctrlNodeType: 2, + }); + assert(result.includes('Repeater'), 'should show "Repeater" for node type 2, got: ' + result); + assert(!/type=2\b/.test(result), 'should not show the raw type number, got: ' + result); + }); + + // #1868 — SNR is wire-encoded (value * 4); a raw 16 must display as 4.00 dB. + test('getDetailPreview converts CONTROL DISCOVER_RESP SNR from wire units to dB', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_RESP', + ctrlSNR: 16, + }); + assert(result.includes('4.00dB') || result.includes('4.00 dB'), 'should show 16/4.0=4.00 dB, got: ' + result); + assert(!/snr=16(?!\.)/.test(result), 'should not show the raw wire SNR value, got: ' + result); }); test('getDetailPreview handles CONTROL UNKNOWN subtype', () => { @@ -830,6 +870,37 @@ console.log('\n=== packets.js: buildFieldTable ==='); assert(result.includes('Raw')); }); + // #1868 — CONTROL no longer falls through to the generic "Raw" row; it + // gets a proper field breakdown matching decodeControl()'s byte layout. + test('buildFieldTable renders CONTROL DISCOVER_REQ with human-readable filter', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_REQ', ctrlFilter: 4, ctrlTag: 0xDEADBEEF, ctrlSince: 0x11223344 }; + const result = api.buildFieldTable(pkt, decoded, [], []); + assert(!result.includes('>Raw<'), 'should not fall through to the generic Raw row, got: ' + result); + assert(result.includes('DISCOVER_REQ')); + assert(result.includes('Repeater'), 'filter=4 (1<<2) should show "Repeater", got: ' + result); + assert(result.includes('DEADBEEF')); + }); + + test('buildFieldTable renders CONTROL DISCOVER_RESP with converted SNR and truncated pubkey when node is unresolved', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_RESP', ctrlNodeType: 2, ctrlSNR: 16, ctrlPubKey: '00'.repeat(32) }; + // 5th arg (ctrlPubKeyNode) omitted -- unresolved case. + const result = api.buildFieldTable(pkt, decoded, [], []); + assert(result.includes('Repeater'), 'node type 2 should show "Repeater", got: ' + result); + assert(result.includes('4.00 dB'), 'SNR 16/4.0 should show 4.00 dB, got: ' + result); + assert(!result.includes('#/nodes/'), 'should not render a node link when unresolved, got: ' + result); + }); + + test('buildFieldTable renders CONTROL DISCOVER_RESP pubkey as a clickable node link when resolved', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_RESP', ctrlPubKey: 'ab'.repeat(32) }; + const ctrlPubKeyNode = { public_key: 'ab'.repeat(32), name: 'KnownRepeater' }; + const result = api.buildFieldTable(pkt, decoded, [], [], ctrlPubKeyNode); + assert(result.includes('#/nodes/' + ctrlPubKeyNode.public_key), 'should link to the resolved node, got: ' + result); + assert(result.includes('KnownRepeater'), 'should show the resolved node name, got: ' + result); + }); + test('buildFieldTable hash_size calculation', () => { // Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, but hash_count = 0 // Since #653: when hashCount == 0, shows "hash_count=0 (direct advert)" instead of hash_size