Skip to content

Commit 02729d5

Browse files
authored
Merge pull request #32 from ZaparooProject/fix/ndef-read-timeout-recovery
fix: resolve NDEF read timeout errors on marginal RF connections
2 parents 8822979 + b5a6cba commit 02729d5

File tree

5 files changed

+213
-66
lines changed

5 files changed

+213
-66
lines changed

communication_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,102 @@ func TestDevice_SendRawCommand(t *testing.T) {
566566
}
567567
}
568568

569+
func TestDevice_SendDataExchangeWithRetry(t *testing.T) {
570+
t.Parallel()
571+
572+
t.Run("Success_FirstAttempt", func(t *testing.T) {
573+
t.Parallel()
574+
575+
device, mock := createMockDeviceWithTransport(t)
576+
577+
// Configure successful response
578+
mock.SetResponse(testutil.CmdInDataExchange, []byte{0x41, 0x00, 0xDE, 0xAD, 0xBE, 0xEF})
579+
580+
result, err := device.SendDataExchangeWithRetry(context.Background(), []byte{0x30, 0x04})
581+
582+
require.NoError(t, err)
583+
assert.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF}, result)
584+
assert.Equal(t, 1, mock.GetCallCount(testutil.CmdInDataExchange))
585+
})
586+
587+
t.Run("Success_AfterTimeoutRetry", func(t *testing.T) {
588+
t.Parallel()
589+
590+
device, mock := createMockDeviceWithTransport(t)
591+
592+
// Queue: first call returns timeout (0x01), second call succeeds
593+
// Timeout error is encoded as protocol error in response byte 1
594+
mock.QueueResponses(testutil.CmdInDataExchange,
595+
[]byte{0x41, 0x01}, // First call: timeout error
596+
[]byte{0x41, 0x00, 0xDE, 0xAD, 0xBE, 0xEF}, // Second call: success
597+
)
598+
599+
result, err := device.SendDataExchangeWithRetry(context.Background(), []byte{0x30, 0x04})
600+
601+
require.NoError(t, err)
602+
assert.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF}, result)
603+
assert.Equal(t, 2, mock.GetCallCount(testutil.CmdInDataExchange), "Should have retried once")
604+
})
605+
606+
t.Run("Success_AfterTwoTimeoutRetries", func(t *testing.T) {
607+
t.Parallel()
608+
609+
device, mock := createMockDeviceWithTransport(t)
610+
611+
// Queue: first two calls timeout, third succeeds
612+
mock.QueueResponses(testutil.CmdInDataExchange,
613+
[]byte{0x41, 0x01}, // First call: timeout error
614+
[]byte{0x41, 0x01}, // Second call: timeout error
615+
[]byte{0x41, 0x00, 0xDE, 0xAD, 0xBE, 0xEF}, // Third call: success
616+
)
617+
618+
result, err := device.SendDataExchangeWithRetry(context.Background(), []byte{0x30, 0x04})
619+
620+
require.NoError(t, err)
621+
assert.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF}, result)
622+
assert.Equal(t, 3, mock.GetCallCount(testutil.CmdInDataExchange), "Should have retried twice")
623+
})
624+
625+
t.Run("Failure_AllThreeTimeoutsExhausted", func(t *testing.T) {
626+
t.Parallel()
627+
628+
device, mock := createMockDeviceWithTransport(t)
629+
630+
// Queue: all three calls timeout
631+
mock.QueueResponses(testutil.CmdInDataExchange,
632+
[]byte{0x41, 0x01}, // First call: timeout error
633+
[]byte{0x41, 0x01}, // Second call: timeout error
634+
[]byte{0x41, 0x01}, // Third call: timeout error
635+
)
636+
637+
result, err := device.SendDataExchangeWithRetry(context.Background(), []byte{0x30, 0x04})
638+
639+
require.Error(t, err)
640+
assert.Nil(t, result)
641+
assert.Equal(t, 3, mock.GetCallCount(testutil.CmdInDataExchange), "Should have tried 3 times")
642+
643+
// Verify it's a timeout error
644+
var pn532Err *PN532Error
645+
require.ErrorAs(t, err, &pn532Err)
646+
assert.True(t, pn532Err.IsTimeoutError())
647+
})
648+
649+
t.Run("NoRetry_NonTimeoutError", func(t *testing.T) {
650+
t.Parallel()
651+
652+
device, mock := createMockDeviceWithTransport(t)
653+
654+
// Configure non-timeout error (0x14 = authentication error)
655+
mock.SetResponse(testutil.CmdInDataExchange, []byte{0x41, 0x14})
656+
657+
result, err := device.SendDataExchangeWithRetry(context.Background(), []byte{0x30, 0x04})
658+
659+
require.Error(t, err)
660+
assert.Nil(t, result)
661+
assert.Equal(t, 1, mock.GetCallCount(testutil.CmdInDataExchange), "Should NOT retry non-timeout errors")
662+
})
663+
}
664+
569665
func TestDevice_PowerDown(t *testing.T) {
570666
t.Parallel()
571667

device_context.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,54 @@ func (d *Device) SendDataExchange(ctx context.Context, data []byte) ([]byte, err
555555
return res[2:], nil
556556
}
557557

558+
// isRetryableTimeoutError checks if an error is a PN532 timeout (0x01) that should be retried.
559+
func isRetryableTimeoutError(err error) bool {
560+
var pn532Err *PN532Error
561+
return errors.As(err, &pn532Err) && pn532Err.IsTimeoutError()
562+
}
563+
564+
// SendDataExchangeWithRetry sends a data exchange command with automatic retry on timeout.
565+
// Error 0x01 (timeout) indicates a transient RF communication failure where the packet was
566+
// lost. Immediate retry typically succeeds as the tag is still in the field.
567+
//
568+
// Configuration:
569+
// - 3 attempts total (1 initial + 2 retries)
570+
// - No delay between retries (immediate retry)
571+
// - Only retries on timeout errors (0x01)
572+
func (d *Device) SendDataExchangeWithRetry(ctx context.Context, data []byte) ([]byte, error) {
573+
const maxAttempts = 3
574+
575+
var lastErr error
576+
for attempt := range maxAttempts {
577+
if ctxErr := ctx.Err(); ctxErr != nil {
578+
if lastErr != nil {
579+
return nil, lastErr
580+
}
581+
return nil, ctxErr
582+
}
583+
584+
result, err := d.SendDataExchange(ctx, data)
585+
if err == nil {
586+
if attempt > 0 {
587+
Debugf("SendDataExchangeWithRetry: succeeded on attempt %d", attempt+1)
588+
}
589+
return result, nil
590+
}
591+
592+
if !isRetryableTimeoutError(err) {
593+
return nil, err
594+
}
595+
596+
// Timeout error - save it and retry immediately
597+
lastErr = err
598+
if attempt < maxAttempts-1 {
599+
Debugf("SendDataExchangeWithRetry: timeout on attempt %d, retrying immediately", attempt+1)
600+
}
601+
}
602+
603+
return nil, lastErr
604+
}
605+
558606
// SendRawCommand sends a raw command with context support
559607
func (d *Device) SendRawCommand(ctx context.Context, data []byte) ([]byte, error) {
560608
const targetNum byte = 1

mifare.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ func (t *MIFARETag) ReadBlock(ctx context.Context, block uint8) ([]byte, error)
307307
return nil, fmt.Errorf("not authenticated to sector %d (block %d)", sector, block)
308308
}
309309

310-
// Send read command
311-
data, err := t.device.SendDataExchange(ctx, []byte{mifareCmdRead, block})
310+
// Send read command with retry on timeout
311+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{mifareCmdRead, block})
312312
if err != nil {
313313
return nil, fmt.Errorf("%w (block %d): %w", ErrTagReadFailed, block, err)
314314
}
@@ -329,10 +329,10 @@ func (t *MIFARETag) ReadBlockDirect(ctx context.Context, block uint8) ([]byte, e
329329
return nil, err
330330
}
331331

332-
// Send read command directly
333-
data, err := t.device.SendDataExchange(ctx, []byte{mifareCmdRead, block})
332+
// Send read command with retry on timeout
333+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{mifareCmdRead, block})
334334
if err != nil {
335-
// If we get a timeout error, try InCommunicateThru as fallback
335+
// If we still get a timeout error after retries, try InCommunicateThru as fallback
336336
if IsPN532TimeoutError(err) {
337337
return t.readBlockCommunicateThru(ctx, block)
338338
}

ntag.go

Lines changed: 25 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ func (t *NTAGTag) ReadBlock(ctx context.Context, block uint8) ([]byte, error) {
167167
return nil, err
168168
}
169169

170-
data, err := t.device.SendDataExchange(ctx, []byte{ntagCmdRead, block})
170+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{ntagCmdRead, block})
171171
if err != nil {
172172
// If we get authentication error 14, try InCommunicateThru as fallback for clone devices
173173
if IsPN532AuthenticationError(err) {
@@ -240,10 +240,9 @@ func (t *NTAGTag) ReadNDEF(ctx context.Context) (*NDEFMessage, error) {
240240
return nil, err
241241
}
242242

243-
// Ensure target is selected before reading. This is critical after DetectType()
244-
// which uses GetVersion() via InCommunicateThru - that command doesn't maintain
245-
// target selection state. Without re-selection, InDataExchange fails with timeout
246-
// error 0x01. See PN532 User Manual §7.3.9.
243+
// Ensure target is selected before reading. This is defensive against any prior
244+
// operations that may have used InCommunicateThru (0x42), which doesn't maintain
245+
// the PN532's target selection state. See PN532 User Manual §7.3.9.
247246
if err := t.device.InSelect(ctx); err != nil {
248247
Debugln("NTAG ReadNDEF: InSelect failed, continuing anyway:", err)
249248
}
@@ -375,7 +374,7 @@ func (t *NTAGTag) readRawPages(ctx context.Context, startPage uint8, pageCount i
375374
}
376375

377376
// NTAG READ command returns 16 bytes (4 pages) starting from the specified page
378-
data, err := t.device.SendDataExchange(ctx, []byte{ntagCmdRead, startPage})
377+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{ntagCmdRead, startPage})
379378
if err != nil {
380379
return nil, err
381380
}
@@ -1065,7 +1064,15 @@ func (t *NTAGTag) GetTotalPages() uint8 {
10651064
}
10661065
}
10671066

1068-
// DetectType attempts to detect the NTAG variant using GET_VERSION command with fallback
1067+
// DetectType detects the NTAG variant using the Capability Container (CC).
1068+
//
1069+
// This function uses CC-based detection exclusively, avoiding the GET_VERSION command
1070+
// which uses InCommunicateThru (0x42). InCommunicateThru doesn't maintain the PN532's
1071+
// target selection state, causing subsequent InDataExchange calls to fail with timeout
1072+
// error 0x01 on readers with marginal RF connections.
1073+
//
1074+
// CC-based detection accurately identifies NTAG213/215/216 from the size field in the
1075+
// capability container, which is sufficient for NDEF operations.
10691076
func (t *NTAGTag) DetectType(ctx context.Context) error {
10701077
if err := ctx.Err(); err != nil {
10711078
return err
@@ -1090,53 +1097,10 @@ func (t *NTAGTag) DetectType(ctx context.Context) error {
10901097
return errors.New("not an NTAG tag: invalid capability container")
10911098
}
10921099

1093-
// Check if this is likely a genuine NXP tag (UID starts with 0x04)
1094-
// Clone tags use different UID prefixes and typically don't support GET_VERSION.
1095-
// Sending GET_VERSION (0x60) to a clone tag that doesn't understand it causes
1096-
// the tag to enter IDLE state per ISO14443-3A, breaking subsequent writes.
1097-
// Skip GET_VERSION for non-NXP UIDs to keep clone tags in ACTIVE state.
1098-
if len(t.uid) > 0 && t.uid[0] != 0x04 {
1099-
Debugf("NTAG DetectType: non-NXP UID prefix 0x%02X, skipping GET_VERSION to avoid IDLE state", t.uid[0])
1100-
t.tagType = t.detectTypeFromCapabilityContainer(ccData)
1101-
return nil
1102-
}
1103-
1104-
// Now try to get the actual version information using GET_VERSION
1105-
version, err := t.GetVersion()
1106-
1107-
// Re-select target after GetVersion to restore PN532 internal state.
1108-
// GetVersion uses InCommunicateThru (0x42) which is a raw pass-through command
1109-
// that doesn't maintain the PN532's target selection state. Without re-selection,
1110-
// subsequent InDataExchange calls may fail with timeout error 01.
1111-
// See PN532 User Manual §7.3.9: "The host controller has to take care of the
1112-
// selection of the target it wants to reach (whereas when using the
1113-
// InDataExchange command, it is done automatically)."
1114-
if selectErr := t.device.InSelect(ctx); selectErr != nil {
1115-
Debugln("NTAG DetectType: InSelect after GetVersion failed:", selectErr)
1116-
}
1117-
1118-
if err != nil {
1119-
// If GET_VERSION fails, we still know it's an NTAG from the CC check
1120-
// Use fallback detection method based on capability container
1121-
Debugf("NTAG GET_VERSION failed, using CC-based detection fallback: %v", err)
1122-
t.tagType = t.detectTypeFromCapabilityContainer(ccData)
1123-
return nil // Don't return error if CC-based detection succeeded
1124-
}
1125-
1126-
// Use the version information to determine the exact variant
1127-
t.tagType = version.GetNTAGType()
1128-
1129-
if t.tagType == NTAGTypeUnknown {
1130-
// Even if storage size is unknown from GET_VERSION, try CC-based detection
1131-
Debugf("NTAG GET_VERSION returned unknown type, trying CC-based detection fallback")
1132-
fallbackType := t.detectTypeFromCapabilityContainer(ccData)
1133-
if fallbackType != NTAGTypeUnknown {
1134-
t.tagType = fallbackType
1135-
} else {
1136-
// Final fallback to conservative choice
1137-
t.tagType = NTAGType213 // Use smallest variant for safety
1138-
}
1139-
}
1100+
// Use CC-based detection for all tags (genuine NXP and clones alike)
1101+
// This avoids GET_VERSION which uses InCommunicateThru and can cause
1102+
// target selection issues on marginal RF connections
1103+
t.tagType = t.detectTypeFromCapabilityContainer(ccData)
11401104

11411105
return nil
11421106
}
@@ -1321,21 +1285,20 @@ func (t *NTAGTag) getTagTypeName() string {
13211285
}
13221286

13231287
func (t *NTAGTag) readBlockWithRetry(ctx context.Context, block uint8) ([]byte, error) {
1324-
maxRetries := 3
1288+
const maxRetries = 3
13251289
for i := range maxRetries {
13261290
if err := ctx.Err(); err != nil {
13271291
return nil, err
13281292
}
13291293

1330-
data, err := t.device.SendDataExchange(ctx, []byte{ntagCmdRead, block})
1294+
// SendDataExchangeWithRetry handles timeout (0x01) retries internally
1295+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{ntagCmdRead, block})
13311296
if err != nil {
13321297
// If we get authentication error 14, try InCommunicateThru as fallback for clone devices
13331298
if IsPN532AuthenticationError(err) {
13341299
return t.readBlockCommunicateThru(ctx, block)
13351300
}
1336-
if i < maxRetries-1 {
1337-
continue
1338-
}
1301+
// For other errors, fail immediately since SendDataExchangeWithRetry already retried timeouts
13391302
return nil, err
13401303
}
13411304

@@ -1344,6 +1307,7 @@ func (t *NTAGTag) readBlockWithRetry(ctx context.Context, block uint8) ([]byte,
13441307
return data[:ntagBlockSize], nil
13451308
}
13461309

1310+
// Short response - retry
13471311
if i < maxRetries-1 {
13481312
continue
13491313
}
@@ -1649,8 +1613,8 @@ func (t *NTAGTag) readBlockDirectFallback(ctx context.Context, block uint8) ([]b
16491613

16501614
Debugf("NTAG attempting direct InDataExchange fallback for block %d", block)
16511615

1652-
// Try direct InDataExchange, ignoring the error 14 that originally triggered the fallback chain
1653-
data, err := t.device.SendDataExchange(ctx, []byte{ntagCmdRead, block})
1616+
// Try direct InDataExchange with retry, ignoring the error 14 that originally triggered the fallback chain
1617+
data, err := t.device.SendDataExchangeWithRetry(ctx, []byte{ntagCmdRead, block})
16541618
if err != nil {
16551619
// If this also fails, the clone device simply doesn't support NTAG properly
16561620
Debugf("NTAG direct InDataExchange fallback also failed: %v", err)

ntag_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,45 @@ func TestNTAGDetectType_SkipsGetVersionForCloneTags(t *testing.T) {
16321632
assert.NotEqual(t, NTAGTypeUnknown, tag.tagType)
16331633
}
16341634

1635+
// TestNTAGDetectType_UsesCCOnlyForNXPTags verifies that even genuine NXP tags
1636+
// (UID starting with 0x04) use CC-based detection and never call GET_VERSION.
1637+
// This is a regression test for the fix that removed GET_VERSION to avoid
1638+
// timeout errors on marginal RF connections. See issue: NDEF read timeout 0x01.
1639+
func TestNTAGDetectType_UsesCCOnlyForNXPTags(t *testing.T) {
1640+
t.Parallel()
1641+
1642+
device, mockTransport := createMockDeviceWithTransport(t)
1643+
1644+
// Use a genuine NXP tag UID (starts with 0x04)
1645+
nxpUID := []byte{0x04, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}
1646+
tag := NewNTAGTag(device, nxpUID, 0x00)
1647+
1648+
// Mock valid CC read for NTAG215 (size 0x3E)
1649+
// CC format: [Magic 0xE1] [Version] [Size] [Access]
1650+
ccData := make([]byte, 16) // Read returns 4 pages = 16 bytes
1651+
ccData[0] = 0xE1 // NDEF magic
1652+
ccData[1] = 0x10 // Version 1.0
1653+
ccData[2] = 0x3E // Size field for NTAG215 (504 bytes)
1654+
ccData[3] = 0x00 // Access conditions
1655+
1656+
// Only set up response for InDataExchange (0x40), NOT for InCommunicateThru (0x42)
1657+
// If GetVersion was being called, it would use InCommunicateThru and fail
1658+
mockTransport.SetResponse(0x40, append([]byte{0x41, 0x00}, ccData...))
1659+
1660+
err := tag.DetectType(context.Background())
1661+
1662+
require.NoError(t, err, "DetectType should succeed with CC-based detection only")
1663+
assert.Equal(t, NTAGType215, tag.tagType, "Should detect NTAG215 from CC size field")
1664+
1665+
// Verify InCommunicateThru was NOT called (GetVersion uses InCommunicateThru)
1666+
assert.Equal(t, 0, mockTransport.GetCallCount(0x42),
1667+
"InCommunicateThru should NOT be called - GetVersion is no longer used")
1668+
1669+
// Verify InDataExchange WAS called (for CC read)
1670+
assert.GreaterOrEqual(t, mockTransport.GetCallCount(0x40), 1,
1671+
"InDataExchange should be called for CC read")
1672+
}
1673+
16351674
// TestNTAGTag_ReadNDEF_FudanClone tests that Fudan clone tags (UID prefix 0x1D)
16361675
// use block-by-block reading instead of FAST_READ, since Fudan FM11NT021 clones
16371676
// don't support FAST_READ (0x3A) and may return garbage or corrupt tag state.

0 commit comments

Comments
 (0)