Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 92 additions & 45 deletions notecard/dfu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
193 changes: 193 additions & 0 deletions notecard/dfu_test.go
Original file line number Diff line number Diff line change
@@ -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 = &notecard.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)
}
}
Loading