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 ===');