Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup
2026-07-28 14:44:31 +02:00
2 changed files with 157 additions and 7 deletions
+85 -6
View File
@@ -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 ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
${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)}
</details>` : ''}
${observations.length > 1 ? `
@@ -3508,7 +3552,7 @@
return rows ? `<table class="detail-decoded" style="width:100%;border-collapse:collapse;margin-top:8px">${rows}</table>` : '';
}
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
? `<a href="#/nodes/${encodeURIComponent(ctrlPubKeyNode.public_key)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(ctrlPubKeyNode.name || ctrlPubKeyNode.public_key.slice(0, 8) + '…')}</a>`
: 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), '');
}
+72 -1
View File
@@ -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