diff --git a/notecard/dfu.go b/notecard/dfu.go index 0828af8..25a9c8e 100644 --- a/notecard/dfu.go +++ b/notecard/dfu.go @@ -22,32 +22,96 @@ import ( // card.binary path. const dfuInlineChunkMax = 8192 -// Side-loads a file to the DFU area of the notecard, to avoid download -func dfuSideload(filename string, noBin bool, verbose bool) (err error) { +// Number of attempts to make when the Notecard rejects a binary transfer with +// {bad-bin}. This mirrors note-c's NoteBinaryStoreTransmit behavior. +const dfuBinaryRetries = 3 - // Do a card.binary transaction to see if the Notecard is capable of - // doing binary sideloads, and if so, how large. The -nobin flag forces - // the slower inline dfu.put path that doesn't use card.binary at all. - binaryMax := 0 - var rsp notecard.Request - if !noBin { - rsp, err = card.TransactionRequest(notecard.Request{Req: "card.binary"}) - if note.ErrorContains(err, note.ErrCardIo) { - return err - } +var dfuBinaryRetryDelay = time.Second - if err == nil { +func dfuBadBinError(err error) bool { + return note.ErrorContains(err, "{bad-bin}") +} + +func dfuRecoverableSetupError(err error) bool { + return note.ErrorContains(err, note.ErrCardIo) || dfuBadBinError(err) +} - // Get the maximum size that the notecard can handle - binaryMax = int(rsp.Max) +// dfuBinaryCapacity clears any prior partial receive, then gets the capacity +// for the fast binary path. Retrying here also handles a USB serial device that +// is still appearing when the CLI starts. +func dfuBinaryCapacity(noBin bool) (int, error) { + if noBin { + return 0, nil + } - // Use shorter delays when sending to Notecard, for performance + var lastErr error + for attempt := 1; attempt <= dfuBinaryRetries; attempt++ { + rsp, err := card.TransactionRequest(notecard.Request{Req: "card.binary", Reset: true}) + if err == nil { + // Use shorter delays when sending to Notecard, for performance. notecard.RequestSegmentMaxLen = 1024 notecard.RequestSegmentDelayMs = 5 + return int(rsp.Max), nil + } + + // Preserve the historical inline fallback for older Notecards that do + // not support card.binary. + if !dfuRecoverableSetupError(err) { + return 0, nil + } + lastErr = err + if attempt < dfuBinaryRetries { + fmt.Printf("recovering binary transfer state (attempt %d/%d): %s\n", attempt, dfuBinaryRetries, err) + time.Sleep(dfuBinaryRetryDelay) } } + return 0, lastErr +} + +// dfuTransferBinary stages and verifies one chunk in the Notecard's binary +// buffer. Its caller handles the bounded retry of a {bad-bin} validation +// failure, matching note-c's binary-store transmit path. +func dfuTransferBinary(payload []byte) error { + payloadEncoded, err := notecard.CobsEncodeAppend(payload, byte('\n'), byte('\n')) + if err != nil { + return err + } + + _, err = card.TransactionRequest(notecard.Request{ + Req: "card.binary.put", + Cobs: int32(len(payloadEncoded) - 1), + }) + if err != nil { + return err + } + + if err = card.SendBytes(payloadEncoded); err != nil { + return err + } + + rsp, err := card.TransactionRequest(notecard.Request{Req: "card.binary"}) + if err != nil { + return err + } + if int(rsp.Length) != len(payload) { + return fmt.Errorf("notecard payload is insufficient (%d sent, %d received)", len(payload), rsp.Length) + } + + return nil +} + +// Side-loads a file to the DFU area of the notecard, to avoid download +func dfuSideload(filename string, noBin bool, verbose bool) (err error) { + + // Determine whether the Notecard supports binary sideloads and clear any + // partial binary receive left by a previous failed invocation. + binaryMax, err := dfuBinaryCapacity(noBin) + if err != nil { + return err + } + // Read the file up-front so we can handle this common failure // before we go into dfu mode var bin []byte @@ -81,7 +145,7 @@ func dfuSideload(filename string, noBin bool, verbose bool) (err error) { return } - rsp, err = card.TransactionRequest(notecard.Request{Req: "card.version"}) + rsp, err := card.TransactionRequest(notecard.Request{Req: "card.version"}) if err != nil { fmt.Printf("card.version request failed\n") return @@ -228,35 +292,19 @@ func loadBin(filetype notehub.UploadType, filename string, bin []byte, binaryMax // If we're doing binary, do the transaction if binaryMax > 0 { - - // Encode COBS - var payloadEncoded []byte - payloadEncoded, err = notecard.CobsEncode(payload, byte('\n')) - if err != nil { - return - } - - // Send the COBS data to the notecard - req2 := notecard.Request{Req: "card.binary.put"} - req2.Cobs = int32(len(payloadEncoded)) - rsp, err = card.TransactionRequest(req2) - if err != nil { - return - } - payloadEncoded = append(payloadEncoded, byte('\n')) - err = card.SendBytes(payloadEncoded) - if err != nil { - return + for attempt := 1; attempt <= dfuBinaryRetries; attempt++ { + err = dfuTransferBinary(payload) + if err == nil || !dfuBadBinError(err) { + break + } + if attempt < dfuBinaryRetries { + fmt.Printf("binary transfer failed at offset %d (%s); waiting %s before retrying the same chunk (attempt %d/%d)\n", + offset, err, dfuBinaryRetryDelay, attempt+1, dfuBinaryRetries) + time.Sleep(dfuBinaryRetryDelay) + } } - - // Verify that the binary made it to the notecard - var rsp2 notecard.Request - rsp2, err = card.TransactionRequest(notecard.Request{Req: "card.binary"}) if err != nil { - return - } - if int(rsp2.Length) != len(payload) { - return fmt.Errorf("notecard payload is insufficient (%d sent, %d received)", len(payload), rsp2.Length) + return fmt.Errorf("binary transfer failed at offset %d after %d attempts: %w", offset, dfuBinaryRetries, err) } // Now that it's been received successfully, remove the payload and @@ -265,7 +313,6 @@ func loadBin(filetype notehub.UploadType, filename string, bin []byte, binaryMax req.Binary = true } - // Perform the request rsp, err = card.TransactionRequest(req) if err != nil { diff --git a/notecard/dfu_test.go b/notecard/dfu_test.go new file mode 100644 index 0000000..09e10b6 --- /dev/null +++ b/notecard/dfu_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package main + +import ( + "encoding/json" + "testing" + + "github.com/blues/note-go/notecard" +) + +func mockCard(t *testing.T, transaction func(notecard.Request, bool, []byte) ([]byte, error)) { + t.Helper() + previousCard := card + card = ¬ecard.Context{ + TransactionFn: func(_ *notecard.Context, _ int, noResponse bool, requestJSON []byte, _ bool) ([]byte, error) { + if noResponse { + return transaction(notecard.Request{}, true, requestJSON) + } + + var request notecard.Request + if err := json.Unmarshal(requestJSON, &request); err != nil { + t.Fatalf("decode request %q: %v", requestJSON, err) + } + return transaction(request, false, nil) + }, + } + t.Cleanup(func() { + card = previousCard + }) +} + +func noDfuBinaryRetryDelay(t *testing.T) { + t.Helper() + previousDelay := dfuBinaryRetryDelay + dfuBinaryRetryDelay = 0 + t.Cleanup(func() { + dfuBinaryRetryDelay = previousDelay + }) +} + +func TestDfuBinaryCapacityResetsStaleReceive(t *testing.T) { + previousSegmentMaxLen := notecard.RequestSegmentMaxLen + previousSegmentDelayMs := notecard.RequestSegmentDelayMs + t.Cleanup(func() { + notecard.RequestSegmentMaxLen = previousSegmentMaxLen + notecard.RequestSegmentDelayMs = previousSegmentDelayMs + }) + + var receivedRequest notecard.Request + mockCard(t, func(request notecard.Request, noResponse bool, _ []byte) ([]byte, error) { + if noResponse { + t.Fatal("card.binary capacity request must expect a response") + } + receivedRequest = request + return []byte(`{"max":16384}`), nil + }) + + capacity, err := dfuBinaryCapacity(false) + if err != nil { + t.Fatalf("dfuBinaryCapacity returned an error: %v", err) + } + if receivedRequest.Req != "card.binary" || !receivedRequest.Reset { + t.Fatalf("capacity request = %#v, want card.binary reset:true", receivedRequest) + } + if capacity != 16384 { + t.Fatalf("capacity = %d, want 16384", capacity) + } +} + +func TestLoadBinRetriesBadBinValidation(t *testing.T) { + noDfuBinaryRetryDelay(t) + + var binaryPuts int + var binarySends int + var dfuPuts []notecard.Request + + mockCard(t, func(request notecard.Request, noResponse bool, _ []byte) ([]byte, error) { + if noResponse { + binarySends++ + return []byte(`{}`), nil + } + + switch request.Req { + case "dfu.put": + if request.Body != nil { + return []byte(`{"length":4}`), nil + } + dfuPuts = append(dfuPuts, request) + return []byte(`{"pending":false}`), nil + case "card.binary.put": + binaryPuts++ + return []byte(`{}`), nil + case "card.binary": + if binarySends == 1 { + return []byte(`{"err":"binary receive prematurely terminated {bad-bin}"}`), nil + } + return []byte(`{"length":4}`), nil + default: + t.Fatalf("unexpected request: %#v", request) + return nil, nil + } + }) + + if err := loadBin("host", "firmware.bin", []byte("0123"), 4); err != nil { + t.Fatalf("loadBin returned an error: %v", err) + } + + if binaryPuts != 2 || binarySends != 2 { + t.Fatalf("binary transfer attempts = %d puts, %d sends; want 2", binaryPuts, binarySends) + } + if len(dfuPuts) != 1 || !dfuPuts[0].Binary || dfuPuts[0].Payload != nil { + t.Fatalf("dfu.put did not use the validated binary payload: %#v", dfuPuts) + } +} + +func TestLoadBinStopsAfterBadBinRetryLimit(t *testing.T) { + noDfuBinaryRetryDelay(t) + + var binaryPuts int + var binarySends int + var dfuPuts int + + mockCard(t, func(request notecard.Request, noResponse bool, _ []byte) ([]byte, error) { + if noResponse { + binarySends++ + return []byte(`{}`), nil + } + + switch request.Req { + case "dfu.put": + if request.Body != nil { + return []byte(`{"length":4}`), nil + } + dfuPuts++ + return []byte(`{"pending":false}`), nil + case "card.binary.put": + binaryPuts++ + return []byte(`{}`), nil + case "card.binary": + return []byte(`{"err":"binary receive prematurely terminated {bad-bin}"}`), nil + default: + t.Fatalf("unexpected request: %#v", request) + return nil, nil + } + }) + + if err := loadBin("host", "firmware.bin", []byte("0123"), 4); err == nil { + t.Fatal("loadBin succeeded after exhausting {bad-bin} retries") + } + if binaryPuts != dfuBinaryRetries || binarySends != dfuBinaryRetries { + t.Fatalf("binary transfer attempts = %d puts, %d sends; want %d", binaryPuts, binarySends, dfuBinaryRetries) + } + if dfuPuts != 0 { + t.Fatalf("dfu.put calls = %d, want 0 after failed binary validation", dfuPuts) + } +} + +func TestLoadBinDoesNotRetryNonBadBinFailure(t *testing.T) { + noDfuBinaryRetryDelay(t) + + var binaryPuts int + mockCard(t, func(request notecard.Request, noResponse bool, _ []byte) ([]byte, error) { + if noResponse { + return []byte(`{}`), nil + } + + switch request.Req { + case "dfu.put": + if request.Body != nil { + return []byte(`{"length":4}`), nil + } + return []byte(`{"pending":false}`), nil + case "card.binary.put": + binaryPuts++ + return []byte(`{}`), nil + case "card.binary": + return []byte(`{"err":"unrelated binary failure"}`), nil + default: + t.Fatalf("unexpected request: %#v", request) + return nil, nil + } + }) + + if err := loadBin("host", "firmware.bin", []byte("0123"), 4); err == nil { + t.Fatal("loadBin succeeded after a non-{bad-bin} validation error") + } + if binaryPuts != 1 { + t.Fatalf("binary transfer attempts = %d, want 1 for a non-{bad-bin} error", binaryPuts) + } +}