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 ? `` : '';
}
- 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