Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
47c8fbc
feat: enhance Windows UART compatibility with platform-specific handling
wizzomafizzo Sep 13, 2025
d494a37
Lint issues
wizzomafizzo Sep 14, 2025
8d049c9
fix: resolve Windows UART timing issues causing 30-second delays and …
wizzomafizzo Sep 14, 2025
9e02fdd
feat: implement comprehensive retry system with command-specific prof…
wizzomafizzo Sep 14, 2025
db6c3dc
adjust polling intervals
wizzomafizzo Sep 14, 2025
9e96f79
lints
wizzomafizzo Sep 15, 2025
f7eb896
tweak ack buffering
wizzomafizzo Sep 15, 2025
f8adab3
tweak again
wizzomafizzo Sep 15, 2025
df46f38
fix deadlock
wizzomafizzo Sep 15, 2025
b91555d
again
wizzomafizzo Sep 15, 2025
a7de543
again
wizzomafizzo Sep 15, 2025
4f8bba6
revert
wizzomafizzo Sep 15, 2025
e3633ca
again
wizzomafizzo Sep 15, 2025
b617550
again
wizzomafizzo Sep 15, 2025
0141ca9
again
wizzomafizzo Sep 15, 2025
3273555
another one
wizzomafizzo Sep 15, 2025
eb71267
Add back hardware polling for inlistpassivetarget
wizzomafizzo Sep 15, 2025
c6dd017
relax timings on uart
wizzomafizzo Sep 15, 2025
210786e
refactor inlist timeout
wizzomafizzo Sep 15, 2025
9bd0960
fix missing default valure for inlist timeout
wizzomafizzo Sep 16, 2025
1976cc1
simplify read timeout
wizzomafizzo Sep 16, 2025
7dba682
fix polling retries
wizzomafizzo Sep 16, 2025
c4ae269
fix ntag size handling
wizzomafizzo Sep 16, 2025
7e73eab
lints
wizzomafizzo Sep 16, 2025
1cb75dc
workarounds for empty tags and fix error checking
wizzomafizzo Sep 17, 2025
ebe766b
fix tests
wizzomafizzo Sep 17, 2025
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ permissions:
jobs:
test:
strategy:
fail-fast: false
matrix:
go-version: ['1.24.x']
os: [ubuntu-latest, macos-latest, windows-latest]
Expand Down Expand Up @@ -43,6 +44,7 @@ jobs:

integration:
strategy:
fail-fast: false
matrix:
go-version: ['1.24.x']
os: [ubuntu-latest, macos-latest, windows-latest]
Expand Down
4 changes: 2 additions & 2 deletions communication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func getDataExchangeErrorCases() []struct {
},
inputData: []byte{0x01, 0x02},
expectError: true,
errorSubstring: "PN532 error: 0x01",
errorSubstring: "PN532 error 0x01",
},
{
name: "Invalid_Response_Format",
Expand Down Expand Up @@ -325,7 +325,7 @@ func getRawCommandErrorCases() []struct {
},
inputData: []byte{0x30, 0x00},
expectError: true,
errorSubstring: "PN532 error: 0x02",
errorSubstring: "PN532 error 0x02",
},
{
name: "Invalid_Response_Format",
Expand Down
51 changes: 51 additions & 0 deletions device.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,57 @@ func (d *Device) IsAutoPollSupported() bool {
return d.hasCapability(CapabilityAutoPollNative)
}

// SetPassiveActivationRetries configures the maximum number of retries for passive activation
// to prevent infinite waiting that can cause the PN532 to lock up. A finite number like 10 (0x0A)
// is recommended instead of 0xFF (infinite) to avoid stuck states requiring power cycling.
func (d *Device) SetPassiveActivationRetries(maxRetries byte) error {
// RF Configuration item 0x05 - MaxRetries
// Payload: [MxRtyATR, MxRtyPSL, MxRtyPassiveActivation]
configPayload := []byte{
0x05, // CfgItem: MaxRetries
0x00, // MxRtyATR (use default)
0x00, // MxRtyPSL (use default)
maxRetries, // MxRtyPassiveActivation
}

_, err := d.transport.SendCommand(cmdRFConfiguration, configPayload)
if err != nil {
return fmt.Errorf("failed to set passive activation retries: %w", err)
}

return nil
}

// SetPollingRetries configures the MxRtyATR parameter for passive target detection retries.
// This controls how many times the PN532 will retry detecting a passive target before giving up.
// Each retry is approximately 150ms according to the PN532 datasheet.
//
// Parameters:
// - mxRtyATR: Number of retries (0x00 = immediate, 0x01-0xFE = retry count, 0xFF = infinite)
//
// Common values:
// - 0x00: Immediate return (no retries)
// - 0x10: ~2.4 seconds (16 retries)
// - 0x20: ~4.8 seconds (32 retries)
// - 0xFF: Infinite retries (use with caution)
func (d *Device) SetPollingRetries(mxRtyATR byte) error {
// RF Configuration item 0x05 - MaxRetries
// Payload: [MxRtyATR, MxRtyPSL, MxRtyPassiveActivation]
configPayload := []byte{
0x05, // CfgItem: MaxRetries
mxRtyATR, // MxRtyATR (retry count for passive target detection)
0x01, // MxRtyPSL (default)
0xFF, // MxRtyPassiveActivation (infinite)
}

_, err := d.transport.SendCommand(cmdRFConfiguration, configPayload)
if err != nil {
return fmt.Errorf("failed to set polling retries: %w", err)
}

return nil
}

// Close closes the device connection
func (d *Device) Close() error {
if d.transport != nil {
Expand Down
106 changes: 104 additions & 2 deletions device_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ func (d *Device) InitContext(ctx context.Context) error {
}
}

// Configure finite passive activation retries to prevent infinite wait lockups
// Use 10 retries (~1 second) instead of default 0xFF (infinite)
if err := d.SetPassiveActivationRetries(0x0A); err != nil {
// Log but don't fail initialization - this is an optimization, not critical
// Some older firmware versions might not support this configuration
_ = err
}

// Get firmware version (if supported by transport)
if !skipFirmwareVersion {
if err := d.setupFirmwareVersion(ctx); err != nil {
Expand Down Expand Up @@ -370,6 +378,20 @@ func (d *Device) detectTagsWithInListPassiveTarget(
return nil, fmt.Errorf("transport preparation failed: %w", err)
}

// Release any previously selected targets to clear HALT states
// This addresses intermittent "empty valid tag" issues where tags get stuck
if err := d.InReleaseContext(ctx, 0); err != nil {
debugf("InRelease failed, continuing anyway: %v", err)
// Don't fail the operation if InRelease fails - it's an optimization
}

// Small delay to allow RF field and tags to stabilize after release
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(10 * time.Millisecond):
}

// Use InListPassiveTarget for legacy compatibility
return d.InListPassiveTargetContext(ctx, maxTags, baudRate)
}
Expand Down Expand Up @@ -549,7 +571,7 @@ func (d *Device) SendDataExchangeContext(ctx context.Context, data []byte) ([]by
// Check for error frame (TFI = 0x7F)
if len(res) >= 2 && res[0] == 0x7F {
errorCode := res[1]
return nil, fmt.Errorf("PN532 error: 0x%02X", errorCode)
return nil, NewPN532Error(errorCode, "InDataExchange", "")
}

if len(res) < 2 || res[0] != 0x41 {
Expand All @@ -571,7 +593,7 @@ func (d *Device) SendRawCommandContext(ctx context.Context, data []byte) ([]byte
// Check for error frame (TFI = 0x7F)
if len(res) >= 2 && res[0] == 0x7F {
errorCode := res[1]
return nil, fmt.Errorf("PN532 error: 0x%02X", errorCode)
return nil, NewPN532Error(errorCode, "InCommunicateThru", "")
}

if len(res) < 2 || res[0] != 0x43 {
Expand Down Expand Up @@ -750,13 +772,93 @@ func (*Device) normalizeMaxTargets(maxTg byte) byte {

// executeInListPassiveTarget sends the InListPassiveTarget command
func (d *Device) executeInListPassiveTarget(ctx context.Context, data []byte) ([]byte, error) {
// Dynamically adjust transport timeout based on mxRtyATR when provided
// mxRtyATR is the 3rd byte of the InListPassiveTarget payload
var prevTimeout time.Duration
if d.config != nil {
prevTimeout = d.config.Timeout
}
computed := d.computeInListHostTimeout(ctx, data)
// Best-effort set; if it fails we'll proceed with previous timeout
_ = d.transport.SetTimeout(computed)
// Restore previous timeout after the command completes
defer func() { _ = d.transport.SetTimeout(prevTimeout) }()

result, err := d.transport.SendCommandWithContext(ctx, cmdInListPassiveTarget, data)
if err != nil {
return nil, fmt.Errorf("failed to send InListPassiveTarget command: %w", err)
}
return result, nil
}

// computeInListHostTimeout derives a host-side timeout from mxRtyATR and context deadline
// According to PN532 docs, each retry is ~150ms. We add baseline slack and bound by context if present.
func (d *Device) computeInListHostTimeout(ctx context.Context, data []byte) time.Duration {
var fallback time.Duration
if d.config != nil {
fallback = d.config.Timeout
}

// When mxRtyATR (3rd byte) is provided, derive a timeout from it.
if len(data) >= 3 {
return d.computeTimeoutFromRetryCount(ctx, data[2], fallback)
}

// No mxRtyATR provided; use context deadline if present or fallback
return d.getContextTimeoutOrFallback(ctx, fallback)
}

// computeTimeoutFromRetryCount calculates timeout based on mxRtyATR retry count
func (d *Device) computeTimeoutFromRetryCount(ctx context.Context, mx byte, fallback time.Duration) time.Duration {
// 0xFF means infinite retry on hardware; rely on context or cap to a safe upper bound
if mx == 0xFF {
return d.handleInfiniteRetry(ctx)
}

// Each retry ~150ms; add small baseline slack for host/driver overhead
expected := time.Duration(int(mx))*150*time.Millisecond + 300*time.Millisecond

// Apply bounds and respect context deadline
return d.applyTimeoutBounds(ctx, expected, fallback)
}

// handleInfiniteRetry handles the special case of infinite retry (0xFF)
func (*Device) handleInfiniteRetry(ctx context.Context) time.Duration {
if deadline, ok := ctx.Deadline(); ok {
if rem := time.Until(deadline); rem > 0 {
return rem
}
}
// No deadline; choose a conservative cap
return 10 * time.Second
}

// applyTimeoutBounds ensures timeout is within reasonable bounds and respects context
func (d *Device) applyTimeoutBounds(ctx context.Context, expected, fallback time.Duration) time.Duration {
// Ensure we don't go below device default
if expected < fallback {
expected = fallback
}

// Cap to a reasonable maximum to avoid excessive blocking when mx is large
if expected > 8*time.Second {
expected = 8 * time.Second
}

// Respect context deadline if sooner
return d.getContextTimeoutOrFallback(ctx, expected)
}

// getContextTimeoutOrFallback returns context deadline if present and positive, otherwise fallback
func (*Device) getContextTimeoutOrFallback(ctx context.Context, fallback time.Duration) time.Duration {
if deadline, ok := ctx.Deadline(); ok {
if rem := time.Until(deadline); rem > 0 && rem < fallback {
return rem
}
}
return fallback
}

// handleInListPassiveTargetError handles command errors with clone device fallback
func (d *Device) handleInListPassiveTargetError(
ctx context.Context, err error, maxTg, brTy byte,
Expand Down
125 changes: 125 additions & 0 deletions device_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,128 @@ func TestDiagnoseContextCancellation(t *testing.T) {
assert.ErrorIs(t, err, context.DeadlineExceeded,
"Expected context.DeadlineExceeded, got: %v", err)
}

// TestDetectTagsWithInListPassiveTarget_CallsInRelease tests that tag detection calls InRelease first
func TestDetectTagsWithInListPassiveTarget_CallsInRelease(t *testing.T) {
t.Parallel()

mock := NewMockTransport()
defer func() { _ = mock.Close() }()

// Set up successful InRelease response (command 0x52)
mock.SetResponse(0x52, []byte{0x53, 0x00}) // InRelease response + success status

// Set up successful InListPassiveTarget response (command 0x4A)
mock.SetResponse(0x4A, []byte{
0x4B, // InListPassiveTarget response
0x01, // Number of targets found
0x01, // Target number
0x00, 0x04, // SENS_RES
0x08, // SEL_RES
0x04, // UID length
0x12, 0x34, 0x56, 0x78, // UID
})

device, err := New(mock)
require.NoError(t, err)

// Call the internal detectTagsWithInListPassiveTarget method
tags, err := device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)

require.NoError(t, err)
require.Len(t, tags, 1)
require.Equal(t, "12345678", tags[0].UID)

// Note: We set up both InRelease and InListPassiveTarget responses above.
// The fact that the detection succeeded implies both were called successfully.
// We can't easily verify the exact call order without modifying MockTransport,
// but the behavior test (success with proper setup) is sufficient.
}

// TestDetectTagsWithInListPassiveTarget_InReleaseFails tests behavior when InRelease fails
func TestDetectTagsWithInListPassiveTarget_InReleaseFails(t *testing.T) {
t.Parallel()

mock := NewMockTransport()
defer func() { _ = mock.Close() }()

// Set up InRelease failure (command 0x52)
mock.SetError(0x52, ErrTransportTimeout)

// Set up successful InListPassiveTarget response despite InRelease failure
mock.SetResponse(0x4A, []byte{
0x4B, // InListPassiveTarget response
0x01, // Number of targets found
0x01, // Target number
0x00, 0x04, // SENS_RES
0x08, // SEL_RES
0x04, // UID length
0x12, 0x34, 0x56, 0x78, // UID
})

device, err := New(mock)
require.NoError(t, err)

// Call should succeed even if InRelease fails
tags, err := device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)

require.NoError(t, err, "Tag detection should succeed even when InRelease fails")
require.Len(t, tags, 1)
require.Equal(t, "12345678", tags[0].UID)
}

// TestDetectTagsWithInListPassiveTarget_WithContext_Cancellation tests context cancellation during delay
func TestDetectTagsWithInListPassiveTarget_WithContext_Cancellation(t *testing.T) {
t.Parallel()

mock := NewMockTransport()
defer func() { _ = mock.Close() }()

// Set up successful InRelease response
mock.SetResponse(0x52, []byte{0x53, 0x00})

device, err := New(mock)
require.NoError(t, err)

// Create a context that will be cancelled quickly
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()

// This should fail due to context cancellation during the delay
_, err = device.detectTagsWithInListPassiveTarget(ctx, 1, 0x00)

require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded, "Should fail with context deadline exceeded")
}

// TestDetectTagsWithInListPassiveTarget_Timing tests that there's a delay after InRelease
func TestDetectTagsWithInListPassiveTarget_Timing(t *testing.T) {
t.Parallel()

mock := NewMockTransport()
defer func() { _ = mock.Close() }()

// Set up responses
mock.SetResponse(0x52, []byte{0x53, 0x00}) // InRelease
mock.SetResponse(0x4A, []byte{
0x4B, // InListPassiveTarget response
0x01, // Number of targets found
0x01, // Target number
0x00, 0x04, // SENS_RES
0x08, // SEL_RES
0x04, // UID length
0x12, 0x34, 0x56, 0x78, // UID
})

device, err := New(mock)
require.NoError(t, err)

start := time.Now()
_, err = device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)
elapsed := time.Since(start)

require.NoError(t, err)
// Should have some delay (at least 5ms) due to the stabilization delay
assert.GreaterOrEqual(t, elapsed, 5*time.Millisecond,
"Should have a delay of at least 5ms for RF field stabilization")
}
Loading
Loading