diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index cba3fe15..ca9932ef 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -169,6 +169,18 @@ type Payload struct { CtrlFlags string `json:"ctrlFlags,omitempty"` CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"` CtrlLength *int `json:"ctrlLength,omitempty"` + // CONTROL DISCOVER_REQ / DISCOVER_RESP body fields (#1802). Subtype is + // "DISCOVER_REQ" | "DISCOVER_RESP" | "UNKNOWN". For REQ: filter, tag, and + // optional since. For RESP: node_type (low nibble of byte0), snr, tag, and + // pubkey (hex; 32 bytes or 8 bytes when prefix_only). All optional — + // emitted only when the body length is sufficient. + CtrlSubtype string `json:"ctrlSubtype,omitempty"` + CtrlFilter *int `json:"ctrlFilter,omitempty"` + CtrlTag *uint32 `json:"ctrlTag,omitempty"` + CtrlSince *uint32 `json:"ctrlSince,omitempty"` + CtrlNodeType *int `json:"ctrlNodeType,omitempty"` + CtrlSNR *int `json:"ctrlSNR,omitempty"` + CtrlPubKey string `json:"ctrlPubKey,omitempty"` // RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per // firmware/src/Mesh.cpp:577 (createRawData). Exposes the bare envelope // shape (length + leading tag) so consumers can triage by app id. @@ -717,21 +729,71 @@ func decodeMultipart(buf []byte) Payload { return p } -// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per -// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset). +// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B). +// +// byte0 high nibble is the control subtype (per firmware/src/Mesh.cpp:69 +// and firmware/examples/simple_repeater/MyMesh.cpp:773-820): +// 0x80 = CTL_TYPE_NODE_DISCOVER_REQ — body: filter:u8 | tag:u32 LE | since:u32 LE (optional) +// 0x90 = CTL_TYPE_NODE_DISCOVER_RESP — body: snr:i8 | tag:u32 LE | pubkey (32B full, or 8B prefix) +// low nibble of byte0 is node_type +// +// The legacy CtrlZeroHop bool (bit7 of byte0) is retained for backwards +// compatibility — it is misleading (bit7 is also set for DISCOVER_RESP) +// and is flagged for follow-up renaming. Length checks gate every field +// extraction; short/truncated bodies emit the subtype only and never panic. func decodeControl(buf []byte) Payload { if len(buf) < 1 { return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)} } zeroHop := buf[0]&0x80 != 0 length := len(buf) - return Payload{ + p := Payload{ Type: "CONTROL", CtrlFlags: fmt.Sprintf("%02x", buf[0]), CtrlZeroHop: &zeroHop, CtrlLength: &length, RawHex: hex.EncodeToString(buf), } + + switch buf[0] & 0xF0 { + case 0x80: + p.CtrlSubtype = "DISCOVER_REQ" + // REQ body: filter:u8 | tag:u32 LE | since:u32 LE (optional). + // Total body (incl. byte0) >= 6 = 1 + 1 + 4. + if len(buf) >= 6 { + filter := int(buf[1]) + p.CtrlFilter = &filter + tag := binary.LittleEndian.Uint32(buf[2:6]) + p.CtrlTag = &tag + if len(buf) >= 10 { + since := binary.LittleEndian.Uint32(buf[6:10]) + p.CtrlSince = &since + } + } + case 0x90: + p.CtrlSubtype = "DISCOVER_RESP" + nodeType := int(buf[0] & 0x0F) + p.CtrlNodeType = &nodeType + // RESP body: snr:i8 | tag:u32 LE | pubkey. Header is 6 bytes + // (byte0 + snr + 4B tag). Pubkey is 32B full or 8B prefix. + if len(buf) >= 6 { + snr := int(int8(buf[1])) + p.CtrlSNR = &snr + tag := binary.LittleEndian.Uint32(buf[2:6]) + p.CtrlTag = &tag + remaining := len(buf) - 6 + if remaining >= 32 { + p.CtrlPubKey = hex.EncodeToString(buf[6:38]) + } else if remaining >= 8 && remaining < 32 { + p.CtrlPubKey = hex.EncodeToString(buf[6:14]) + } + // Other lengths (e.g. 4B): omit pubkey rather than emit a + // partial/ambiguous value. + } + default: + p.CtrlSubtype = "UNKNOWN" + } + return p } // decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). Application-defined diff --git a/cmd/ingestor/issue1802_test.go b/cmd/ingestor/issue1802_test.go new file mode 100644 index 00000000..bbe8bb53 --- /dev/null +++ b/cmd/ingestor/issue1802_test.go @@ -0,0 +1,139 @@ +package main + +// Tests for #1802 — CONTROL DISCOVER_REQ / DISCOVER_RESP decode. +// Wire format references: +// firmware/src/Mesh.cpp:69 (CTL_TYPE constants) +// firmware/examples/simple_repeater/MyMesh.cpp:773-820 +// +// Subtype = byte0 & 0xF0. 0x80 = DISCOVER_REQ, 0x90 = DISCOVER_RESP. +// REQ body (>=6B after byte0): filter:u8 | tag:u32 LE | since:u32 LE (optional) +// RESP body (>=6+pubkey): snr:i8 | tag:u32 LE | pubkey (32B full, or 8B prefix) + +import ( + "encoding/binary" + "testing" +) + +func TestDecodeControl_DiscoverReq_FullBody(t *testing.T) { + // byte0 = 0x80 (DISCOVER_REQ), filter=0x02, tag=0xDEADBEEF, since=0x11223344. + buf := []byte{0x80, 0x02, 0xEF, 0xBE, 0xAD, 0xDE, 0x44, 0x33, 0x22, 0x11} + p := decodeControl(buf) + if p.Type != "CONTROL" { + t.Fatalf("type=%q want CONTROL", p.Type) + } + if p.CtrlSubtype != "DISCOVER_REQ" { + t.Errorf("ctrlSubtype=%q want DISCOVER_REQ", p.CtrlSubtype) + } + if p.CtrlFilter == nil || *p.CtrlFilter != 0x02 { + t.Errorf("ctrlFilter=%v want 2", p.CtrlFilter) + } + if p.CtrlTag == nil || *p.CtrlTag != 0xDEADBEEF { + t.Errorf("ctrlTag=%v want 0xDEADBEEF", p.CtrlTag) + } + if p.CtrlSince == nil || *p.CtrlSince != 0x11223344 { + t.Errorf("ctrlSince=%v want 0x11223344", p.CtrlSince) + } +} + +func TestDecodeControl_DiscoverReq_NoSince(t *testing.T) { + // 6-byte body (no optional since): byte0=0x80, filter, tag. + buf := []byte{0x80, 0x04, 0x01, 0x02, 0x03, 0x04} + p := decodeControl(buf) + if p.CtrlSubtype != "DISCOVER_REQ" { + t.Errorf("ctrlSubtype=%q want DISCOVER_REQ", p.CtrlSubtype) + } + if p.CtrlFilter == nil || *p.CtrlFilter != 0x04 { + t.Errorf("ctrlFilter=%v want 4", p.CtrlFilter) + } + wantTag := binary.LittleEndian.Uint32([]byte{0x01, 0x02, 0x03, 0x04}) + if p.CtrlTag == nil || *p.CtrlTag != wantTag { + t.Errorf("ctrlTag=%v want %#x", p.CtrlTag, wantTag) + } + if p.CtrlSince != nil { + t.Errorf("ctrlSince should be nil for 6B REQ body, got %v", p.CtrlSince) + } +} + +func TestDecodeControl_DiscoverResp_FullPubKey(t *testing.T) { + // byte0 = 0x90 | 0x02 (node_type=2, REPEATER), snr=0x10, tag, 32B pubkey. + body := []byte{0x92, 0x10, 0x44, 0x33, 0x22, 0x11} + for i := 0; i < 32; i++ { + body = append(body, byte(i)) + } + p := decodeControl(body) + if p.CtrlSubtype != "DISCOVER_RESP" { + t.Errorf("ctrlSubtype=%q want DISCOVER_RESP", p.CtrlSubtype) + } + if p.CtrlNodeType == nil || *p.CtrlNodeType != 2 { + t.Errorf("ctrlNodeType=%v want 2", p.CtrlNodeType) + } + if p.CtrlSNR == nil || *p.CtrlSNR != 0x10 { + t.Errorf("ctrlSNR=%v want 16", p.CtrlSNR) + } + wantTag := binary.LittleEndian.Uint32([]byte{0x44, 0x33, 0x22, 0x11}) + if p.CtrlTag == nil || *p.CtrlTag != wantTag { + t.Errorf("ctrlTag=%v want %#x", p.CtrlTag, wantTag) + } + if len(p.CtrlPubKey) != 64 { + t.Fatalf("ctrlPubKey hex len=%d want 64 (32B)", len(p.CtrlPubKey)) + } + if p.CtrlPubKey[:4] != "0001" { + t.Errorf("ctrlPubKey prefix=%q want 0001…", p.CtrlPubKey[:4]) + } +} + +func TestDecodeControl_DiscoverResp_PrefixPubKey(t *testing.T) { + // 6 header bytes + 8 pubkey bytes only (prefix_only path). + body := []byte{0x91, 0xF0, 0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} + p := decodeControl(body) + if p.CtrlSubtype != "DISCOVER_RESP" { + t.Errorf("ctrlSubtype=%q want DISCOVER_RESP", p.CtrlSubtype) + } + if p.CtrlNodeType == nil || *p.CtrlNodeType != 1 { + t.Errorf("ctrlNodeType=%v want 1", p.CtrlNodeType) + } + // snr = signed 0xF0 = -16 + if p.CtrlSNR == nil || *p.CtrlSNR != -16 { + t.Errorf("ctrlSNR=%v want -16", p.CtrlSNR) + } + if len(p.CtrlPubKey) != 16 { + t.Errorf("ctrlPubKey hex len=%d want 16 (8B)", len(p.CtrlPubKey)) + } +} + +func TestDecodeControl_DiscoverResp_TruncatedPubKey(t *testing.T) { + // 6 header bytes + 4 pubkey bytes — neither 8 nor 32. Must NOT panic; + // subtype emitted, pubkey omitted. + body := []byte{0x91, 0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44} + p := decodeControl(body) + if p.CtrlSubtype != "DISCOVER_RESP" { + t.Errorf("ctrlSubtype=%q want DISCOVER_RESP", p.CtrlSubtype) + } + if p.CtrlPubKey != "" { + t.Errorf("ctrlPubKey=%q want empty for 4B pubkey blob", p.CtrlPubKey) + } +} + +func TestDecodeControl_ShortBody_NoSubtypeFields(t *testing.T) { + // byte0=0x80 only (no body). Must emit subtype, no panic, no body fields. + buf := []byte{0x80} + p := decodeControl(buf) + if p.CtrlSubtype != "DISCOVER_REQ" { + t.Errorf("ctrlSubtype=%q want DISCOVER_REQ", p.CtrlSubtype) + } + if p.CtrlFilter != nil { + t.Errorf("ctrlFilter should be nil, got %v", p.CtrlFilter) + } + if p.CtrlTag != nil { + t.Errorf("ctrlTag should be nil, got %v", p.CtrlTag) + } +} + +func TestDecodeControl_UnknownSubtype(t *testing.T) { + // byte0 = 0xA0 — not DISCOVER_REQ/RESP. Subtype = "UNKNOWN". + buf := []byte{0xA0, 0x11, 0x22} + p := decodeControl(buf) + if p.CtrlSubtype != "UNKNOWN" { + t.Errorf("ctrlSubtype=%q want UNKNOWN", p.CtrlSubtype) + } +} diff --git a/public/packets.js b/public/packets.js index 4bb4f518..ff3a7822 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2874,6 +2874,45 @@ if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return ` ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Anonymous requests if (decoded.type === 'ANON_REQ') return ` anon → ${decoded.destHash?.slice(0,8) || '?'}`; + // CONTROL packets (#1802) — DISCOVER_REQ / DISCOVER_RESP body fields, + // decoded by cmd/ingestor/decoder.go decodeControl(). Wire format: + // firmware/src/Mesh.cpp:69 + // firmware/examples/simple_repeater/MyMesh.cpp:773-820 + // Subtype enum on byte0 high nibble; body fields are length-gated and + // may be absent on truncated packets, so each is rendered only when set. + if (decoded.type === 'CONTROL') { + const subtype = decoded.ctrlSubtype || 'CONTROL'; + const icon = ``; + const parts = []; + if (subtype === 'DISCOVER_REQ') { + if (decoded.ctrlFilter != null) { + parts.push(`filter=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')}`); + } + if (decoded.ctrlSince != null) { + parts.push(`since=${Number(decoded.ctrlSince) >>> 0}`); + } + } else if (subtype === 'DISCOVER_RESP') { + if (decoded.ctrlNodeType != null) { + parts.push(`type=${Number(decoded.ctrlNodeType)}`); + } + if (decoded.ctrlSNR != null) { + parts.push(`snr=${Number(decoded.ctrlSNR)}`); + } + 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)}`); + } + } else if (decoded.ctrlFlags) { + parts.push(`flags=0x${escapeHtml(decoded.ctrlFlags)}`); + } + const tail = parts.length ? ` ${parts.join(' ')}` : ''; + return `${icon} ${escapeHtml(subtype)}${tail}`; + } // Companion bridge text if (decoded.text) return escapeHtml(decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text); // Bare adverts with just pubkey diff --git a/test-packets.js b/test-packets.js index a2703feb..6734005b 100644 --- a/test-packets.js +++ b/test-packets.js @@ -499,6 +499,46 @@ console.log('\n=== packets.js: getDetailPreview ==='); test('getDetailPreview returns empty for empty decoded', () => { assert.strictEqual(api.getDetailPreview({}), ''); }); + + // #1802 — CONTROL DISCOVER_REQ / DISCOVER_RESP should be rendered (not just + // hex). Backend cmd/ingestor/decoder.go decodeControl() emits ctrlSubtype + + // body fields; the detail preview must surface them. + test('getDetailPreview handles CONTROL DISCOVER_REQ', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_REQ', + ctrlFilter: 2, + ctrlTag: 0xDEADBEEF, + ctrlSince: 0x11223344, + }); + assert(result.includes('DISCOVER_REQ'), 'should label subtype'); + assert(result.includes('filter'), 'should render filter field'); + assert(result.includes('tag'), 'should render tag field'); + }); + + test('getDetailPreview handles CONTROL DISCOVER_RESP', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_RESP', + ctrlNodeType: 2, + ctrlSNR: 16, + ctrlTag: 0x11223344, + ctrlPubKey: '0001020304050607', + }); + 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'); + }); + + test('getDetailPreview handles CONTROL UNKNOWN subtype', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'UNKNOWN', + ctrlFlags: 'a0', + }); + assert(result.includes('UNKNOWN') || result.includes('CONTROL'), + 'should at least label the unknown subtype'); + }); } console.log('\n=== packets.js: getPathHopCount ===');