Skip to content

Commit a3adaa8

Browse files
authored
Merge pull request #13 from ZaparooProject/fix/windows-uart-compatibility
feat: enhance Windows UART compatibility with platform-specific handling
2 parents a908944 + ebe766b commit a3adaa8

22 files changed

+1999
-117
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ permissions:
1212
jobs:
1313
test:
1414
strategy:
15+
fail-fast: false
1516
matrix:
1617
go-version: ['1.24.x']
1718
os: [ubuntu-latest, macos-latest, windows-latest]
@@ -43,6 +44,7 @@ jobs:
4344

4445
integration:
4546
strategy:
47+
fail-fast: false
4648
matrix:
4749
go-version: ['1.24.x']
4850
os: [ubuntu-latest, macos-latest, windows-latest]

communication_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func getDataExchangeErrorCases() []struct {
185185
},
186186
inputData: []byte{0x01, 0x02},
187187
expectError: true,
188-
errorSubstring: "PN532 error: 0x01",
188+
errorSubstring: "PN532 error 0x01",
189189
},
190190
{
191191
name: "Invalid_Response_Format",
@@ -325,7 +325,7 @@ func getRawCommandErrorCases() []struct {
325325
},
326326
inputData: []byte{0x30, 0x00},
327327
expectError: true,
328-
errorSubstring: "PN532 error: 0x02",
328+
errorSubstring: "PN532 error 0x02",
329329
},
330330
{
331331
name: "Invalid_Response_Format",

device.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,57 @@ func (d *Device) IsAutoPollSupported() bool {
408408
return d.hasCapability(CapabilityAutoPollNative)
409409
}
410410

411+
// SetPassiveActivationRetries configures the maximum number of retries for passive activation
412+
// to prevent infinite waiting that can cause the PN532 to lock up. A finite number like 10 (0x0A)
413+
// is recommended instead of 0xFF (infinite) to avoid stuck states requiring power cycling.
414+
func (d *Device) SetPassiveActivationRetries(maxRetries byte) error {
415+
// RF Configuration item 0x05 - MaxRetries
416+
// Payload: [MxRtyATR, MxRtyPSL, MxRtyPassiveActivation]
417+
configPayload := []byte{
418+
0x05, // CfgItem: MaxRetries
419+
0x00, // MxRtyATR (use default)
420+
0x00, // MxRtyPSL (use default)
421+
maxRetries, // MxRtyPassiveActivation
422+
}
423+
424+
_, err := d.transport.SendCommand(cmdRFConfiguration, configPayload)
425+
if err != nil {
426+
return fmt.Errorf("failed to set passive activation retries: %w", err)
427+
}
428+
429+
return nil
430+
}
431+
432+
// SetPollingRetries configures the MxRtyATR parameter for passive target detection retries.
433+
// This controls how many times the PN532 will retry detecting a passive target before giving up.
434+
// Each retry is approximately 150ms according to the PN532 datasheet.
435+
//
436+
// Parameters:
437+
// - mxRtyATR: Number of retries (0x00 = immediate, 0x01-0xFE = retry count, 0xFF = infinite)
438+
//
439+
// Common values:
440+
// - 0x00: Immediate return (no retries)
441+
// - 0x10: ~2.4 seconds (16 retries)
442+
// - 0x20: ~4.8 seconds (32 retries)
443+
// - 0xFF: Infinite retries (use with caution)
444+
func (d *Device) SetPollingRetries(mxRtyATR byte) error {
445+
// RF Configuration item 0x05 - MaxRetries
446+
// Payload: [MxRtyATR, MxRtyPSL, MxRtyPassiveActivation]
447+
configPayload := []byte{
448+
0x05, // CfgItem: MaxRetries
449+
mxRtyATR, // MxRtyATR (retry count for passive target detection)
450+
0x01, // MxRtyPSL (default)
451+
0xFF, // MxRtyPassiveActivation (infinite)
452+
}
453+
454+
_, err := d.transport.SendCommand(cmdRFConfiguration, configPayload)
455+
if err != nil {
456+
return fmt.Errorf("failed to set polling retries: %w", err)
457+
}
458+
459+
return nil
460+
}
461+
411462
// Close closes the device connection
412463
func (d *Device) Close() error {
413464
if d.transport != nil {

device_context.go

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ func (d *Device) InitContext(ctx context.Context) error {
4545
}
4646
}
4747

48+
// Configure finite passive activation retries to prevent infinite wait lockups
49+
// Use 10 retries (~1 second) instead of default 0xFF (infinite)
50+
if err := d.SetPassiveActivationRetries(0x0A); err != nil {
51+
// Log but don't fail initialization - this is an optimization, not critical
52+
// Some older firmware versions might not support this configuration
53+
_ = err
54+
}
55+
4856
// Get firmware version (if supported by transport)
4957
if !skipFirmwareVersion {
5058
if err := d.setupFirmwareVersion(ctx); err != nil {
@@ -370,6 +378,20 @@ func (d *Device) detectTagsWithInListPassiveTarget(
370378
return nil, fmt.Errorf("transport preparation failed: %w", err)
371379
}
372380

381+
// Release any previously selected targets to clear HALT states
382+
// This addresses intermittent "empty valid tag" issues where tags get stuck
383+
if err := d.InReleaseContext(ctx, 0); err != nil {
384+
debugf("InRelease failed, continuing anyway: %v", err)
385+
// Don't fail the operation if InRelease fails - it's an optimization
386+
}
387+
388+
// Small delay to allow RF field and tags to stabilize after release
389+
select {
390+
case <-ctx.Done():
391+
return nil, ctx.Err()
392+
case <-time.After(10 * time.Millisecond):
393+
}
394+
373395
// Use InListPassiveTarget for legacy compatibility
374396
return d.InListPassiveTargetContext(ctx, maxTags, baudRate)
375397
}
@@ -549,7 +571,7 @@ func (d *Device) SendDataExchangeContext(ctx context.Context, data []byte) ([]by
549571
// Check for error frame (TFI = 0x7F)
550572
if len(res) >= 2 && res[0] == 0x7F {
551573
errorCode := res[1]
552-
return nil, fmt.Errorf("PN532 error: 0x%02X", errorCode)
574+
return nil, NewPN532Error(errorCode, "InDataExchange", "")
553575
}
554576

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

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

751773
// executeInListPassiveTarget sends the InListPassiveTarget command
752774
func (d *Device) executeInListPassiveTarget(ctx context.Context, data []byte) ([]byte, error) {
775+
// Dynamically adjust transport timeout based on mxRtyATR when provided
776+
// mxRtyATR is the 3rd byte of the InListPassiveTarget payload
777+
var prevTimeout time.Duration
778+
if d.config != nil {
779+
prevTimeout = d.config.Timeout
780+
}
781+
computed := d.computeInListHostTimeout(ctx, data)
782+
// Best-effort set; if it fails we'll proceed with previous timeout
783+
_ = d.transport.SetTimeout(computed)
784+
// Restore previous timeout after the command completes
785+
defer func() { _ = d.transport.SetTimeout(prevTimeout) }()
786+
753787
result, err := d.transport.SendCommandWithContext(ctx, cmdInListPassiveTarget, data)
754788
if err != nil {
755789
return nil, fmt.Errorf("failed to send InListPassiveTarget command: %w", err)
756790
}
757791
return result, nil
758792
}
759793

794+
// computeInListHostTimeout derives a host-side timeout from mxRtyATR and context deadline
795+
// According to PN532 docs, each retry is ~150ms. We add baseline slack and bound by context if present.
796+
func (d *Device) computeInListHostTimeout(ctx context.Context, data []byte) time.Duration {
797+
var fallback time.Duration
798+
if d.config != nil {
799+
fallback = d.config.Timeout
800+
}
801+
802+
// When mxRtyATR (3rd byte) is provided, derive a timeout from it.
803+
if len(data) >= 3 {
804+
return d.computeTimeoutFromRetryCount(ctx, data[2], fallback)
805+
}
806+
807+
// No mxRtyATR provided; use context deadline if present or fallback
808+
return d.getContextTimeoutOrFallback(ctx, fallback)
809+
}
810+
811+
// computeTimeoutFromRetryCount calculates timeout based on mxRtyATR retry count
812+
func (d *Device) computeTimeoutFromRetryCount(ctx context.Context, mx byte, fallback time.Duration) time.Duration {
813+
// 0xFF means infinite retry on hardware; rely on context or cap to a safe upper bound
814+
if mx == 0xFF {
815+
return d.handleInfiniteRetry(ctx)
816+
}
817+
818+
// Each retry ~150ms; add small baseline slack for host/driver overhead
819+
expected := time.Duration(int(mx))*150*time.Millisecond + 300*time.Millisecond
820+
821+
// Apply bounds and respect context deadline
822+
return d.applyTimeoutBounds(ctx, expected, fallback)
823+
}
824+
825+
// handleInfiniteRetry handles the special case of infinite retry (0xFF)
826+
func (*Device) handleInfiniteRetry(ctx context.Context) time.Duration {
827+
if deadline, ok := ctx.Deadline(); ok {
828+
if rem := time.Until(deadline); rem > 0 {
829+
return rem
830+
}
831+
}
832+
// No deadline; choose a conservative cap
833+
return 10 * time.Second
834+
}
835+
836+
// applyTimeoutBounds ensures timeout is within reasonable bounds and respects context
837+
func (d *Device) applyTimeoutBounds(ctx context.Context, expected, fallback time.Duration) time.Duration {
838+
// Ensure we don't go below device default
839+
if expected < fallback {
840+
expected = fallback
841+
}
842+
843+
// Cap to a reasonable maximum to avoid excessive blocking when mx is large
844+
if expected > 8*time.Second {
845+
expected = 8 * time.Second
846+
}
847+
848+
// Respect context deadline if sooner
849+
return d.getContextTimeoutOrFallback(ctx, expected)
850+
}
851+
852+
// getContextTimeoutOrFallback returns context deadline if present and positive, otherwise fallback
853+
func (*Device) getContextTimeoutOrFallback(ctx context.Context, fallback time.Duration) time.Duration {
854+
if deadline, ok := ctx.Deadline(); ok {
855+
if rem := time.Until(deadline); rem > 0 && rem < fallback {
856+
return rem
857+
}
858+
}
859+
return fallback
860+
}
861+
760862
// handleInListPassiveTargetError handles command errors with clone device fallback
761863
func (d *Device) handleInListPassiveTargetError(
762864
ctx context.Context, err error, maxTg, brTy byte,

device_context_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,128 @@ func TestDiagnoseContextCancellation(t *testing.T) {
8989
assert.ErrorIs(t, err, context.DeadlineExceeded,
9090
"Expected context.DeadlineExceeded, got: %v", err)
9191
}
92+
93+
// TestDetectTagsWithInListPassiveTarget_CallsInRelease tests that tag detection calls InRelease first
94+
func TestDetectTagsWithInListPassiveTarget_CallsInRelease(t *testing.T) {
95+
t.Parallel()
96+
97+
mock := NewMockTransport()
98+
defer func() { _ = mock.Close() }()
99+
100+
// Set up successful InRelease response (command 0x52)
101+
mock.SetResponse(0x52, []byte{0x53, 0x00}) // InRelease response + success status
102+
103+
// Set up successful InListPassiveTarget response (command 0x4A)
104+
mock.SetResponse(0x4A, []byte{
105+
0x4B, // InListPassiveTarget response
106+
0x01, // Number of targets found
107+
0x01, // Target number
108+
0x00, 0x04, // SENS_RES
109+
0x08, // SEL_RES
110+
0x04, // UID length
111+
0x12, 0x34, 0x56, 0x78, // UID
112+
})
113+
114+
device, err := New(mock)
115+
require.NoError(t, err)
116+
117+
// Call the internal detectTagsWithInListPassiveTarget method
118+
tags, err := device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)
119+
120+
require.NoError(t, err)
121+
require.Len(t, tags, 1)
122+
require.Equal(t, "12345678", tags[0].UID)
123+
124+
// Note: We set up both InRelease and InListPassiveTarget responses above.
125+
// The fact that the detection succeeded implies both were called successfully.
126+
// We can't easily verify the exact call order without modifying MockTransport,
127+
// but the behavior test (success with proper setup) is sufficient.
128+
}
129+
130+
// TestDetectTagsWithInListPassiveTarget_InReleaseFails tests behavior when InRelease fails
131+
func TestDetectTagsWithInListPassiveTarget_InReleaseFails(t *testing.T) {
132+
t.Parallel()
133+
134+
mock := NewMockTransport()
135+
defer func() { _ = mock.Close() }()
136+
137+
// Set up InRelease failure (command 0x52)
138+
mock.SetError(0x52, ErrTransportTimeout)
139+
140+
// Set up successful InListPassiveTarget response despite InRelease failure
141+
mock.SetResponse(0x4A, []byte{
142+
0x4B, // InListPassiveTarget response
143+
0x01, // Number of targets found
144+
0x01, // Target number
145+
0x00, 0x04, // SENS_RES
146+
0x08, // SEL_RES
147+
0x04, // UID length
148+
0x12, 0x34, 0x56, 0x78, // UID
149+
})
150+
151+
device, err := New(mock)
152+
require.NoError(t, err)
153+
154+
// Call should succeed even if InRelease fails
155+
tags, err := device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)
156+
157+
require.NoError(t, err, "Tag detection should succeed even when InRelease fails")
158+
require.Len(t, tags, 1)
159+
require.Equal(t, "12345678", tags[0].UID)
160+
}
161+
162+
// TestDetectTagsWithInListPassiveTarget_WithContext_Cancellation tests context cancellation during delay
163+
func TestDetectTagsWithInListPassiveTarget_WithContext_Cancellation(t *testing.T) {
164+
t.Parallel()
165+
166+
mock := NewMockTransport()
167+
defer func() { _ = mock.Close() }()
168+
169+
// Set up successful InRelease response
170+
mock.SetResponse(0x52, []byte{0x53, 0x00})
171+
172+
device, err := New(mock)
173+
require.NoError(t, err)
174+
175+
// Create a context that will be cancelled quickly
176+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
177+
defer cancel()
178+
179+
// This should fail due to context cancellation during the delay
180+
_, err = device.detectTagsWithInListPassiveTarget(ctx, 1, 0x00)
181+
182+
require.Error(t, err)
183+
assert.ErrorIs(t, err, context.DeadlineExceeded, "Should fail with context deadline exceeded")
184+
}
185+
186+
// TestDetectTagsWithInListPassiveTarget_Timing tests that there's a delay after InRelease
187+
func TestDetectTagsWithInListPassiveTarget_Timing(t *testing.T) {
188+
t.Parallel()
189+
190+
mock := NewMockTransport()
191+
defer func() { _ = mock.Close() }()
192+
193+
// Set up responses
194+
mock.SetResponse(0x52, []byte{0x53, 0x00}) // InRelease
195+
mock.SetResponse(0x4A, []byte{
196+
0x4B, // InListPassiveTarget response
197+
0x01, // Number of targets found
198+
0x01, // Target number
199+
0x00, 0x04, // SENS_RES
200+
0x08, // SEL_RES
201+
0x04, // UID length
202+
0x12, 0x34, 0x56, 0x78, // UID
203+
})
204+
205+
device, err := New(mock)
206+
require.NoError(t, err)
207+
208+
start := time.Now()
209+
_, err = device.detectTagsWithInListPassiveTarget(context.Background(), 1, 0x00)
210+
elapsed := time.Since(start)
211+
212+
require.NoError(t, err)
213+
// Should have some delay (at least 5ms) due to the stabilization delay
214+
assert.GreaterOrEqual(t, elapsed, 5*time.Millisecond,
215+
"Should have a delay of at least 5ms for RF field stabilization")
216+
}

0 commit comments

Comments
 (0)