fix(dispenser): retry on transient prepare failure

This commit is contained in:
yurii 2026-09-22 18:27:57 +01:00
parent 1969aaa1d2
commit aeb86f55b9
4 changed files with 70 additions and 1 deletions

View File

@ -33,7 +33,7 @@ import (
)
const (
buildVersion = "v2.0.1"
buildVersion = "v2.0.2"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)

View File

@ -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)
}

View File

@ -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)},

View File

@ -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