mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 22:23:50 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
+85
-6
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user