From d0d36e0532dc90509835a8a88c3f404a8a8d0d34 Mon Sep 17 00:00:00 2001 From: you Date: Sun, 29 Mar 2026 14:04:37 +0000 Subject: [PATCH] fix: align packet decoder with MeshCore firmware spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared decoder.js against the MeshCore firmware source (Dispatcher.cpp, Packet.h, Mesh.cpp, AdvertDataHelpers.h) and fixed all mismatches: 1. Field order: transport codes now parsed BEFORE path_length byte, matching the spec: [header][transport_codes?][path_length][path][payload] 2. ACK payload: was incorrectly decoded as dest(1)+src(1)+ackHash(4). Firmware shows ACK is just checksum(4) — no dest/src hashes. 3. TRACE payload: was incorrectly decoded as flags(1)+tag(4)+dest(6)+src(1). Firmware shows tag(4)+authCode(4)+flags(1)+pathData. 4. ADVERT appdata: added missing feature1 (0x20 flag) and feature2 (0x40 flag) parsing — 2-byte fields between location and name. 5. Transport code field naming: renamed nextHop/lastHop to code1/code2 to match spec terminology (transport_code_1/transport_code_2). 6. Fixed incorrect field size labels in packets.js hex breakdown: dest/src are 1 byte, MAC is 2 bytes (not 6B/6B/4B). 7. Fixed ANON_REQ/PATH comment typos (dest was listed as 6 bytes, MAC as 4 bytes — both wrong, code was already correct). All 329 tests pass (66 decoder + 263 spec/golden). --- decoder.js | 56 ++++++++++++++++++++++++++------------------ public/packets.js | 12 ++++------ test-decoder-spec.js | 21 +++++++++-------- test-decoder.js | 32 ++++++++++++------------- 4 files changed, 65 insertions(+), 56 deletions(-) diff --git a/decoder.js b/decoder.js index 16270de4..cf267860 100644 --- a/decoder.js +++ b/decoder.js @@ -2,8 +2,8 @@ * MeshCore Packet Decoder * Custom implementation — does NOT use meshcore-decoder library (known path_length bug). * - * Packet layout: - * [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...] + * Packet layout (per firmware docs/packet_format.md): + * [header(1)] [transportCodes?(4)] [pathLength(1)] [path hops] [payload...] * * Header byte (LSB first): * bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT) @@ -42,7 +42,7 @@ const PAYLOAD_TYPES = { 0x0F: 'RAW_CUSTOM', }; -// Route types that carry transport codes (nextHop + lastHop, 2 bytes each) +// Route types that carry transport codes (2x uint16_t, 4 bytes total) const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT // --- Header parsing --- @@ -94,13 +94,11 @@ function decodeEncryptedPayload(buf) { }; } -/** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */ +/** ACK: checksum(4) — CRC of message timestamp + text + sender pubkey (per Mesh.cpp createAck) */ function decodeAck(buf) { - if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') }; + if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') }; return { - destHash: buf.subarray(0, 1).toString('hex'), - srcHash: buf.subarray(1, 2).toString('hex'), - extraHash: buf.subarray(2, 6).toString('hex'), + ackChecksum: buf.subarray(0, 4).toString('hex'), }; } @@ -125,6 +123,8 @@ function decodeAdvert(buf) { room: advType === 3, sensor: advType === 4, hasLocation: !!(flags & 0x10), + hasFeat1: !!(flags & 0x20), + hasFeat2: !!(flags & 0x40), hasName: !!(flags & 0x80), }; @@ -134,6 +134,14 @@ function decodeAdvert(buf) { result.lon = appdata.readInt32LE(off + 4) / 1e6; off += 8; } + if (result.flags.hasFeat1 && appdata.length >= off + 2) { + result.feat1 = appdata.readUInt16LE(off); + off += 2; + } + if (result.flags.hasFeat2 && appdata.length >= off + 2) { + result.feat2 = appdata.readUInt16LE(off); + off += 2; + } if (result.flags.hasName) { // Find null terminator to separate name from trailing telemetry bytes let nameEnd = appdata.length; @@ -231,7 +239,7 @@ function decodeGrpTxt(buf, channelKeys) { return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData }; } -/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */ +/** ANON_REQ: dest(1) + ephemeral_pubkey(32) + MAC(2) + encrypted */ function decodeAnonReq(buf) { if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') }; return { @@ -242,7 +250,7 @@ function decodeAnonReq(buf) { }; } -/** PATH: dest(6) + src(6) + MAC(4) + path_data */ +/** PATH: dest(1) + src(1) + MAC(2) + path_data */ function decodePath_payload(buf) { if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') }; return { @@ -253,14 +261,14 @@ function decodePath_payload(buf) { }; } -/** TRACE: flags(1) + tag(4) + dest(6) + src(1) */ +/** TRACE: tag(4) + authCode(4) + flags(1) + pathData (per Mesh.cpp onRecvPacket TRACE) */ function decodeTrace(buf) { - if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') }; + if (buf.length < 9) return { error: 'too short', raw: buf.toString('hex') }; return { - flags: buf[0], - tag: buf.readUInt32LE(1), - destHash: buf.subarray(5, 11).toString('hex'), - srcHash: buf.subarray(11, 12).toString('hex'), + tag: buf.readUInt32LE(0), + authCode: buf.subarray(4, 8).toString('hex'), + flags: buf[8], + pathData: buf.subarray(9).toString('hex'), }; } @@ -289,20 +297,22 @@ function decodePacket(hexString, channelKeys) { if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)'); const header = decodeHeader(buf[0]); - const pathByte = buf[1]; - let offset = 2; + let offset = 1; - // Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT + // Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT — BEFORE path_length per spec let transportCodes = null; if (TRANSPORT_ROUTES.has(header.routeType)) { if (buf.length < offset + 4) throw new Error('Packet too short for transport codes'); transportCodes = { - nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(), - lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(), + code1: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(), + code2: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(), }; offset += 4; } + // Path length byte — AFTER transport codes per spec + const pathByte = buf[offset++]; + // Path const path = decodePath(pathByte, buf, offset); offset += path.bytesConsumed; @@ -386,7 +396,7 @@ module.exports = { decodePacket, validateAdvert, hasNonPrintableChars, ROUTE_TYP // --- Tests --- if (require.main === module) { - console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ==='); + console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ==='); const pkt1 = decodePacket( '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172' ); @@ -402,7 +412,7 @@ if (require.main === module) { assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000'); assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818'); assert(pkt1.transportCodes === null, 'FLOOD has no transport codes'); - assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"'); + assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"'); console.log('✅ Test 1 passed\n'); console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ==='); diff --git a/public/packets.js b/public/packets.js index 5c8fdcef..b379b97d 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1512,14 +1512,12 @@ rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', ''); if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, ''); } else if (decoded.type === 'ACK') { - rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', ''); - rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', ''); - rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', ''); + rows += fieldRow(off, 'Checksum (4B)', decoded.ackChecksum || '', ''); } else if (decoded.destHash !== undefined) { - rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', ''); - rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', ''); - rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', ''); - rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); + rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', ''); + 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 { rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } diff --git a/test-decoder-spec.js b/test-decoder-spec.js index 71628963..853826fa 100644 --- a/test-decoder-spec.js +++ b/test-decoder-spec.js @@ -122,13 +122,14 @@ console.log('── Spec Tests: Transport Codes ──'); { // Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes - // Route type 0: header byte = 0bPPPPPP00, e.g. 0x14 = payloadType 5 (GRP_TXT), routeType 0 - const hex = '1400' + 'AABB' + 'CCDD' + '1A' + '00'.repeat(10); // transport codes + GRP_TXT payload + // Route type 0: header=0x14 = payloadType 5 (GRP_TXT), routeType 0 (TRANSPORT_FLOOD) + // Format: header(1) + transportCodes(4) + pathByte(1) + payload + const hex = '14' + 'AABB' + 'CCDD' + '00' + '1A' + '00'.repeat(10); // transport codes + pathByte + GRP_TXT payload const p = decodePacket(hex); assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)'); assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD'); - assertEq(p.transportCodes.nextHop, 'AABB', 'transport: nextHop'); - assertEq(p.transportCodes.lastHop, 'CCDD', 'transport: lastHop'); + assertEq(p.transportCodes.code1, 'AABB', 'transport: code1'); + assertEq(p.transportCodes.code2, 'CCDD', 'transport: code2'); } { @@ -257,13 +258,13 @@ console.log('── Spec Tests: Advert Payload ──'); console.log('── Spec Tests: Encrypted Payload Format ──'); -// NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext -// But decoder reads dest(6) + src(6) + MAC(4) + ciphertext -// This is a known discrepancy — the decoder matches production behavior, not the spec. -// The spec may describe the firmware's internal addressing while the OTA format differs, -// or the decoder may be parsing the fields differently. Production data validates the decoder. +// Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher — decoder matches this. { - note('Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher, but decoder reads dest(6)+src(6)+MAC(4)+cipher — decoder matches prod data'); + const hex = '0100' + 'AA' + 'BB' + 'CCDD' + '00'.repeat(10); + const p = decodePacket(hex); + assertEq(p.payload.destHash, 'aa', 'encrypted payload: dest is 1 byte'); + assertEq(p.payload.srcHash, 'bb', 'encrypted payload: src is 1 byte'); + assertEq(p.payload.mac, 'ccdd', 'encrypted payload: MAC is 2 bytes'); } console.log('── Spec Tests: validateAdvert ──'); diff --git a/test-decoder.js b/test-decoder.js index 86480922..28efcfd4 100644 --- a/test-decoder.js +++ b/test-decoder.js @@ -28,22 +28,22 @@ test('FLOOD + ADVERT = 0x11', () => { }); test('TRANSPORT_FLOOD = routeType 0', () => { - // 0x00 = TRANSPORT_FLOOD + REQ(0), needs transport codes + 16 byte payload - const hex = '0000' + 'AABB' + 'CCDD' + '00'.repeat(16); + // header=0x00 (TRANSPORT_FLOOD + REQ), transportCodes=AABB+CCDD, pathByte=0x00, payload + const hex = '00' + 'AABB' + 'CCDD' + '00' + '00'.repeat(16); const p = decodePacket(hex); assert.strictEqual(p.header.routeType, 0); assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD'); assert.notStrictEqual(p.transportCodes, null); - assert.strictEqual(p.transportCodes.nextHop, 'AABB'); - assert.strictEqual(p.transportCodes.lastHop, 'CCDD'); + assert.strictEqual(p.transportCodes.code1, 'AABB'); + assert.strictEqual(p.transportCodes.code2, 'CCDD'); }); test('TRANSPORT_DIRECT = routeType 3', () => { - const hex = '0300' + '1122' + '3344' + '00'.repeat(16); + const hex = '03' + '1122' + '3344' + '00' + '00'.repeat(16); const p = decodePacket(hex); assert.strictEqual(p.header.routeType, 3); assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT'); - assert.strictEqual(p.transportCodes.nextHop, '1122'); + assert.strictEqual(p.transportCodes.code1, '1122'); }); test('DIRECT = routeType 2, no transport codes', () => { @@ -358,9 +358,7 @@ test('ACK decode', () => { const hex = '0D00' + '00'.repeat(18); const p = decodePacket(hex); assert.strictEqual(p.payload.type, 'ACK'); - assert(p.payload.destHash); - assert(p.payload.srcHash); - assert(p.payload.extraHash); + assert(p.payload.ackChecksum); }); test('ACK too short', () => { @@ -424,9 +422,9 @@ test('TRACE decode', () => { const hex = '2500' + '00'.repeat(12); const p = decodePacket(hex); assert.strictEqual(p.payload.type, 'TRACE'); - assert.strictEqual(p.payload.flags, 0); assert(p.payload.tag !== undefined); - assert(p.payload.destHash); + assert(p.payload.authCode !== undefined); + assert.strictEqual(p.payload.flags, 0); }); test('TRACE too short', () => { @@ -460,16 +458,18 @@ test('Transport route too short throws', () => { assert.throws(() => decodePacket('0000'), /too short for transport/); }); -test('Corrupt packet #183 — path overflow capped to buffer', () => { +test('Corrupt packet #183 — TRANSPORT_DIRECT with correct field order', () => { const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D'; const p = decodePacket(hex); assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT'); assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN'); - // pathByte 0xAD claims 45 hops × 3 bytes = 135, but only 65 bytes available + // transport codes are bytes 1-4, pathByte=0x87 at byte 5 + assert.strictEqual(p.transportCodes.code1, 'AD67'); + assert.strictEqual(p.transportCodes.code2, '97EC'); + // pathByte 0x87: hashSize=3, hashCount=7 assert.strictEqual(p.path.hashSize, 3); - assert.strictEqual(p.path.hashCount, 21, 'hashCount capped to fit buffer'); - assert.strictEqual(p.path.hops.length, 21); - assert.strictEqual(p.path.truncated, true); + assert.strictEqual(p.path.hashCount, 7); + assert.strictEqual(p.path.hops.length, 7); // No empty strings in hops assert(p.path.hops.every(h => h.length > 0), 'no empty hops'); });