diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index f948a9be..a6ca6a32 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -91,6 +91,11 @@ type Payload struct { MAC string `json:"mac,omitempty"` EncryptedData string `json:"encryptedData,omitempty"` ExtraHash string `json:"extraHash,omitempty"` + // Extended ACK fields per firmware 1.16.0 (issue #1610) — populated by + // decodeAck once the server-side re-decoder is upgraded (issue #1694). + AckLen *int `json:"ackLen,omitempty"` + AckAttempt *int `json:"ackAttempt,omitempty"` + AckRand *int `json:"ackRand,omitempty"` PubKey string `json:"pubKey,omitempty"` Timestamp uint32 `json:"timestamp,omitempty"` TimestampISO string `json:"timestampISO,omitempty"` @@ -124,6 +129,11 @@ type Payload struct { InnerType *int `json:"innerType,omitempty"` InnerTypeName string `json:"innerTypeName,omitempty"` InnerAckCrc string `json:"innerAckCrc,omitempty"` + // Extended ACK inner fields (issue #1610 / #1694) — populated by + // decodeMultipart once ACK parity is ported from the ingestor. + InnerAckLen *int `json:"innerAckLen,omitempty"` + InnerAckAttempt *int `json:"innerAckAttempt,omitempty"` + InnerAckRand *int `json:"innerAckRand,omitempty"` InnerPayload string `json:"innerPayload,omitempty"` // CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per // firmware/src/Mesh.cpp:69 — high-bit = zero-hop direct subset. @@ -241,10 +251,27 @@ func decodeAck(buf []byte) Payload { return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} } checksum := binary.LittleEndian.Uint32(buf[0:4]) - return Payload{ + ackLen := len(buf) + if ackLen > 6 { + ackLen = 6 + } + p := Payload{ Type: "ACK", ExtraHash: fmt.Sprintf("%08x", checksum), + AckLen: &ackLen, } + // Firmware 1.16.0 extended ACK (issue #1610): 5th byte is the attempt + // counter (commit f6e6fdaa), 6th byte is a random byte added so identical + // attempts still hash uniquely (commit a130a95a). + if len(buf) >= 5 { + attempt := int(buf[4]) + p.AckAttempt = &attempt + } + if len(buf) >= 6 { + rnd := int(buf[5]) + p.AckRand = &rnd + } + return p } func decodeAdvert(buf []byte, validateSignatures bool) Payload { @@ -378,6 +405,22 @@ func decodeMultipart(buf []byte) Payload { if innerType == PayloadACK && len(buf) >= 5 { crc := binary.LittleEndian.Uint32(buf[1:5]) p.InnerAckCrc = fmt.Sprintf("%08x", crc) + // Firmware 1.16.0 extended ACK (issue #1610): inner ACK blob may be + // 5 or 6 bytes (payload_len = 1 + ack_len) instead of always 4. + // Attempt counter added in commit f6e6fdaa, RNG byte in commit a130a95a. + ackLen := len(buf) - 1 + if ackLen > 6 { + ackLen = 6 + } + p.InnerAckLen = &ackLen + if len(buf) >= 6 { + attempt := int(buf[5]) + p.InnerAckAttempt = &attempt + } + if len(buf) >= 7 { + rnd := int(buf[6]) + p.InnerAckRand = &rnd + } } else if len(buf) > 1 { p.InnerPayload = hex.EncodeToString(buf[1:]) } diff --git a/cmd/server/decoder_ack_extended_test.go b/cmd/server/decoder_ack_extended_test.go new file mode 100644 index 00000000..0624dc70 --- /dev/null +++ b/cmd/server/decoder_ack_extended_test.go @@ -0,0 +1,96 @@ +package main + +// Tests for issue #1694 — server-side decoder parity with the ingestor's +// firmware-1.16.0 extended ACK support (issue #1610). Wire vectors mirror +// the ingestor's tests so both decoders agree byte-for-byte. +// +// - decodeAck: firmware/src/helpers/BaseChatMesh.cpp:218-234 +// - decodeMultipart: firmware/src/Mesh.cpp:287-310 + +import "testing" + +func TestDecodeAckExtended(t *testing.T) { + tests := []struct { + name string + buf []byte + wantLen int + wantAttPtr bool + wantAtt int + wantRndPtr bool + wantRnd int + }{ + { + name: "legacy 4-byte ACK (CRC only)", + buf: []byte{0xEF, 0xBE, 0xAD, 0xDE}, + wantLen: 4, + }, + { + name: "5-byte ACK (CRC + attempt)", + buf: []byte{0xEF, 0xBE, 0xAD, 0xDE, 0x07}, + wantLen: 5, + wantAttPtr: true, + wantAtt: 7, + }, + { + name: "6-byte ACK (CRC + attempt + rand)", + buf: []byte{0xEF, 0xBE, 0xAD, 0xDE, 0x07, 0x42}, + wantLen: 6, + wantAttPtr: true, + wantAtt: 7, + wantRndPtr: true, + wantRnd: 0x42, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := decodeAck(tc.buf) + if p.Type != "ACK" { + t.Fatalf("type=%q want ACK", p.Type) + } + if p.AckLen == nil { + t.Fatalf("AckLen=nil want %d", tc.wantLen) + } + if *p.AckLen != tc.wantLen { + t.Errorf("AckLen=%d want %d", *p.AckLen, tc.wantLen) + } + if tc.wantAttPtr { + if p.AckAttempt == nil { + t.Errorf("AckAttempt=nil want %d", tc.wantAtt) + } else if *p.AckAttempt != tc.wantAtt { + t.Errorf("AckAttempt=%d want %d", *p.AckAttempt, tc.wantAtt) + } + } else if p.AckAttempt != nil { + t.Errorf("AckAttempt=%d want nil", *p.AckAttempt) + } + if tc.wantRndPtr { + if p.AckRand == nil { + t.Errorf("AckRand=nil want %d", tc.wantRnd) + } else if *p.AckRand != tc.wantRnd { + t.Errorf("AckRand=%d want %d", *p.AckRand, tc.wantRnd) + } + } else if p.AckRand != nil { + t.Errorf("AckRand=%d want nil", *p.AckRand) + } + }) + } +} + +func TestDecodeMultipartAckExtendedInner(t *testing.T) { + // byte0 = (remaining<<4)|inner_type = (3<<4)|0x03 = 0x33 + // inner ACK = CRC(deadbeef LE) + attempt(0x07) + rand(0x42) = 6 bytes + // total buf = 1 + 6 = 7 bytes. + buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE, 0x07, 0x42} + p := decodeMultipart(buf) + if p.InnerAckCrc != "deadbeef" { + t.Fatalf("InnerAckCrc=%q want deadbeef", p.InnerAckCrc) + } + if p.InnerAckLen == nil || *p.InnerAckLen != 6 { + t.Errorf("InnerAckLen=%v want 6", p.InnerAckLen) + } + if p.InnerAckAttempt == nil || *p.InnerAckAttempt != 7 { + t.Errorf("InnerAckAttempt=%v want 7", p.InnerAckAttempt) + } + if p.InnerAckRand == nil || *p.InnerAckRand != 0x42 { + t.Errorf("InnerAckRand=%v want 0x42", p.InnerAckRand) + } +}