From aeb86f55b96462435f3e90d7ea10d12239efb2a3 Mon Sep 17 00:00:00 2001 From: yurii Date: Tue, 22 Sep 2026 18:27:57 +0100 Subject: [PATCH] fix(dispenser): retry on transient prepare failure --- cmd/hardlink/main.go | 2 +- internal/dispenser/dispenserclient.go | 32 ++++++++++++++++++++ internal/dispenser/dispenserclient_test.go | 34 ++++++++++++++++++++++ release notes.md | 3 ++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index abe2b48..c931330 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -33,7 +33,7 @@ import ( ) const ( - buildVersion = "v2.0.1" + buildVersion = "v2.0.2" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) diff --git a/internal/dispenser/dispenserclient.go b/internal/dispenser/dispenserclient.go index d1473f1..02bc35f 100644 --- a/internal/dispenser/dispenserclient.go +++ b/internal/dispenser/dispenserclient.go @@ -201,10 +201,14 @@ func (c *Client) handle(req cmdReq) { case cmdToEncoder: err := cardToEncoderPosition(c.port) + // A movement command makes any previously cached position unreliable. + c.invalidateStatusCache() req.respCh <- cmdResp{err: err} case cmdOutOfMouth: err := cardOutOfMouth(c.port) + // A movement command makes any previously cached position unreliable. + c.invalidateStatusCache() req.respCh <- cmdResp{err: err} default: @@ -246,6 +250,13 @@ func (c *Client) CheckStatus(ctx context.Context) ([]byte, error) { return c.do(ctx, cmdStatus) } +func (c *Client) invalidateStatusCache() { + c.mu.Lock() + c.lastStatus = nil + c.lastStatusT = time.Time{} + c.mu.Unlock() +} + func (c *Client) ToEncoder(ctx context.Context) error { _, err := c.do(ctx, cmdToEncoder) return err @@ -339,6 +350,27 @@ func preparationStatus(operation string, status []byte) (bool, error) { if isPreparationMoving(status) { return false, nil } + + // Some dispenser firmware briefly reports 0x32 ("Preparing card fails") + // while the card is still travelling to the encoder. Treat that one + // diagnostic as transient during an active preparation sequence and let + // pollForEncoderPosition decide success (0x33) or timeout. Do not mask + // independent hard errors reported in the other status bytes. + if status[0] == 0x32 { + statusWithoutPrepareFailure := append([]byte(nil), status...) + statusWithoutPrepareFailure[0] = 0x30 + if err := dispenserStatusError(statusWithoutPrepareFailure); err != nil { + return false, fmt.Errorf("[%s] %w", operation, err) + } + + log.Warnf( + "[%s] transient Preparing card fails; waiting for encoder position, raw status: % X", + operation, + status, + ) + return false, nil + } + if err := dispenserStatusError(status); err != nil { return false, fmt.Errorf("[%s] %w", operation, err) } diff --git a/internal/dispenser/dispenserclient_test.go b/internal/dispenser/dispenserclient_test.go index cb3c019..0a4cdd0 100644 --- a/internal/dispenser/dispenserclient_test.go +++ b/internal/dispenser/dispenserclient_test.go @@ -147,6 +147,40 @@ func TestPrepareCurrentCardAcceptsEncoderPositionWithStaleDiagnostics(t *testing } } +func TestPrepareCurrentCardToleratesTransientPrepareFailureUntilEncoder(t *testing.T) { + client, device := newSequenceTestClient(t, + cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}}, + cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}}, + cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}}, + cmdResp{status: []byte{0x32, 0x30, 0x30, 0x33}}, + ) + + stock, err := client.PrepareCurrentCard(context.Background()) + if err != nil { + t.Fatal(err) + } + if stock != "Preparing card fails" { + t.Fatalf("stock status = %q, want Preparing card fails", stock) + } + if got := commandCount(device.commands, cmdToEncoder); got != 1 { + t.Fatalf("to-encoder commands = %d, want 1", got) + } + if got := commandCount(device.commands, cmdStatus); got != 4 { + t.Fatalf("status reads = %d, want 4", got) + } +} + +func TestPrepareCurrentCardDoesNotMaskHardErrorAlongsideTransientPrepareFailure(t *testing.T) { + client, _ := newSequenceTestClient(t, + cmdResp{status: []byte{0x32, 0x30, 0x32, 0x30}}, + ) + + _, err := client.PrepareCurrentCard(context.Background()) + if err == nil || !strings.Contains(err.Error(), "Card jammed") { + t.Fatalf("error = %v, want Card jammed", err) + } +} + func TestPrepareCurrentCardPollingAllowsTransientMovementWithStaleErrors(t *testing.T) { client, device := newSequenceTestClient(t, cmdResp{status: status(0x34)}, diff --git a/release notes.md b/release notes.md index 2d67969..8a98e68 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### v2.0.2 - 22 September 2026 +fix(dispenser): retry on transient prepare failure + #### v2.0.1 - 10 September 2026 feat: add CreditCall preauth streaming