fix(#1694): port extended ACK decoder to server (ackLen/ackAttempt/ackRand parity) (#1695)

## Summary

Ports the firmware-1.16.0 extended ACK decoding from the ingestor (PR
#1618, issue #1610) into the server-side re-decoder. Previously
`cmd/server/decoder.go` silently dropped `ackLen`, `ackAttempt`, and
`ackRand` (and the multipart inner equivalents) — the server emitted
plain 4-byte ACKs even when the wire carried the 5/6-byte extended form.
Now both decoders agree byte-for-byte.

Closes #1694.

## What changed

- `cmd/server/decoder.go::decodeAck`: sets `AckLen` (capped at 6),
`AckAttempt` (`buf[4]` when `len>=5`), `AckRand` (`buf[5]` when
`len>=6`). Mirrors `cmd/ingestor/decoder.go:279-305`.
- `cmd/server/decoder.go::decodeMultipart` ACK branch: sets `InnerAckLen
= len(buf)-1` (capped at 6), `InnerAckAttempt`, `InnerAckRand`. Mirrors
`cmd/ingestor/decoder.go:696-714`.
- `Payload` struct gains six `*int` fields tagged `omitempty`: `AckLen`,
`AckAttempt`, `AckRand`, `InnerAckLen`, `InnerAckAttempt`,
`InnerAckRand`. Backward-compatible JSON — legacy 4-byte ACKs leave
attempt/rand nil and the fields are omitted from the output.

No other decoder consumer is touched. Routes / store auto-surface the
new fields via JSON marshaling.

## Test layout

`cmd/server/decoder_ack_extended_test.go` drives `decodeAck`
table-driven across the three wire shapes:

| Buffer | AckLen | AckAttempt | AckRand |
|---|---|---|---|
| `EF BE AD DE` (CRC only) | 4 | nil | nil |
| `EF BE AD DE 07` | 5 | 7 | nil |
| `EF BE AD DE 07 42` | 6 | 7 | 0x42 |

Plus `TestDecodeMultipartAckExtendedInner` for a 7-byte multipart buffer
(`0x33` header + 6-byte inner ACK), asserting `InnerAckLen=6`,
`InnerAckAttempt=7`, `InnerAckRand=0x42`.

## TDD trail

- **Red commit** (test + struct stubs only,
`decodeAck`/`decodeMultipart` unchanged) → assertions fail on
`AckLen=nil`.
- **Green commit** (port implementation) → all assertions pass.

Full `cd cmd/server && go test ./...` passes locally.

## Firmware refs

- `firmware/src/helpers/BaseChatMesh.cpp:218-234` (extended ACK layout)
- firmware commit `f6e6fdaa` (attempt counter)
- firmware commit `a130a95a` (RNG byte)

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
This commit is contained in:
Kpa-clawbot
2026-06-12 19:10:44 -07:00
committed by GitHub
co-authored by Kpa-clawbot
parent 547b141530
commit e96f0f9f9f
2 changed files with 140 additions and 1 deletions
+44 -1
View File
@@ -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:])
}
+96
View File
@@ -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)
}
}