Compare commits

..

5 Commits

11 changed files with 1807 additions and 539 deletions

1
.gitignore vendored
View File

@ -29,6 +29,7 @@ _obj
_test
.vscode/
ChipDNAClient/
docs/
# Architecture specific extensions/prefixes
*.[568vq]

View File

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

View File

@ -0,0 +1,363 @@
package dispenser
import (
"context"
"errors"
"io"
"reflect"
"testing"
"time"
)
func apReply(st []byte) []byte {
frame := []byte{STX, 0x30, 0x30, 0, byte(len(st) + 2), 'S', 'F'}
frame = append(frame, st...)
frame = append(frame, ETX)
return append(frame, calculateBCC(frame))
}
func workerRequest(c *Client, ctx context.Context, typ cmdType) cmdResp {
ch := make(chan cmdResp, 1)
c.handle(cmdReq{typ: typ, ctx: ctx, respCh: ch})
return <-ch
}
func TestDeliveryDispatchBoundary(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
short int
badACK bool
cancelAfter int
pending bool
wantErr bool
}{
{name: "success", pending: true},
{name: "command short write", short: 1, wantErr: true},
{name: "bad ACK", badACK: true, wantErr: true},
{name: "ENQ short write", short: 2, pending: true, wantErr: true},
{name: "cancel before ENQ", cancelAfter: 1, wantErr: true},
{name: "cancel after ENQ", cancelAfter: 2, pending: true, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ack := append([]byte(nil), vendorACK...)
if tc.badACK {
ack[0] = 0
}
p := &scriptedTransport{chunks: [][]byte{ack}, shortWrite: tc.short}
p.afterWrite = func() {
if len(p.writes) == tc.cancelAfter {
cancel()
}
}
c := &Client{port: p}
r := workerRequest(c, ctx, cmdOutOfMouth)
if (r.err != nil) != tc.wantErr || c.deliveryPending != tc.pending {
t.Errorf("FC0(%s): err=%v pending=%v, want error=%v pending=%v", tc.name, r.err, c.deliveryPending, tc.wantErr, tc.pending)
}
if len(p.writes) > 2 {
t.Errorf("FC0 writes=%d, want no resend", len(p.writes))
}
})
}
t.Run("definite failure preserves previous pending", func(t *testing.T) {
c := &Client{port: &scriptedTransport{writeErr: io.ErrClosedPipe}, deliveryPending: true}
workerRequest(c, context.Background(), cmdOutOfMouth)
if !c.deliveryPending {
t.Error("failed FC0 erased previous pending")
}
})
t.Run("ENQ transport error remains pending", func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK}}
p.afterWrite = func() {
if len(p.writes) == 2 {
p.writeErr = io.ErrClosedPipe
}
}
c := &Client{port: p}
r := workerRequest(c, context.Background(), cmdOutOfMouth)
if !errors.Is(r.err, io.ErrClosedPipe) || !c.deliveryPending {
t.Errorf("ENQ error=%v pending=%v, want closed pipe and pending", r.err, c.deliveryPending)
}
})
}
func TestWorkerClearanceValidation(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
st []byte
clear bool
}{
{"clear", status(0x30), true},
{"low stock clear", []byte{0x30, 0x30, 0x31, 0x30}, true},
{"residual encoder", status(0x33), false},
{"all sensors", status(0x37), false},
{"ready", status(0x34), true},
{"unknown diagnostic", []byte{0x30, 0x30, 0xFF, 0x30}, true},
{"jam", []byte{0x30, 0x30, 0x32, 0x30}, true},
{"overlap", []byte{0x30, 0x30, 0x34, 0x30}, true},
{"rejection", []byte{0x36, 0x30, 0x30, 0x30}, true},
{"empty", status(0x38), false},
{"preparing", []byte{0x31, 0x30, 0x30, 0x30}, false},
{"dispensing", []byte{0x30, 0x38, 0x30, 0x34}, false},
{"capturing", []byte{0x30, 0x34, 0x30, 0x30}, false},
{"ready with stale errors", []byte{0x36, 0x32, 0x34, 0x34}, true},
{"short payload", []byte{0x30, 0x30, 0x30}, false},
{"unknown position", status(0x40), false},
} {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}}
c := &Client{port: p, deliveryPending: true, deliveryStarted: time.Now().Add(-deliveryMinimumWait), lastStatus: status(0x30), lastStatusT: time.Now(), statusTTL: time.Hour}
r := workerRequest(c, context.Background(), cmdStatus)
if c.deliveryPending == tc.clear {
t.Errorf("FC7(% X): err=%v pending=%v, want clear=%v", tc.st, r.err, c.deliveryPending, tc.clear)
}
want := []string{"AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("commands=%v, want %v", got, want)
}
})
}
t.Run("bad BCC cannot clear", func(t *testing.T) {
frame := apReply(status(0x30))
frame[len(frame)-1] ^= 1
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
c := &Client{port: p, deliveryPending: true}
r := workerRequest(c, context.Background(), cmdStatus)
if r.err == nil || !c.deliveryPending {
t.Errorf("bad BCC: err=%v pending=%v", r.err, c.deliveryPending)
}
})
}
func wireCommands(p *scriptedTransport) []string {
var commands []string
for _, w := range p.writes {
if len(w) > 6 && w[0] == STX {
commands = append(commands, string(w[5:len(w)-2]))
}
}
return commands
}
func deliveryWorkerClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) {
t.Helper()
transportAddress(t)
now := time.Unix(0, 0)
c := &Client{port: p, reqCh: make(chan cmdReq, 16), done: make(chan struct{})}
c.sequenceTiming = sequenceTiming{now: func() time.Time { return now }, wait: func(ctx context.Context, d time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
now = now.Add(d)
return nil
}}
stopped := make(chan struct{})
go func() { defer close(stopped); c.loop() }()
t.Cleanup(func() { c.Close(); <-stopped })
return c, &now
}
func TestDeliveryIncidentReplay(t *testing.T) {
for _, next := range []bool{false, true} {
t.Run(map[bool]string{false: "current", true: "next"}[next], func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{
vendorACK, apReply(status(0x33)), // current card at encoder; encoding succeeds
vendorACK, // FC0
vendorACK, apReply(status(0x37)), // best effort must defer
vendorACK, apReply(status(0x33)), // previous encoder sensor must not succeed
vendorACK, apReply(status(0x34)),
vendorACK, // FC7
vendorACK, apReply(status(0x33)),
}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
// A cached clear result must not authorize FC7.
c.mu.Lock()
c.lastStatus = status(0x30)
c.lastStatusT = time.Now()
c.statusTTL = time.Hour
c.mu.Unlock()
if err := c.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC0", "AP", "AP", "AP", "FC7"}) {
t.Fatalf("prestaging commands=%v, want clearance APs then one FC7", got)
}
prepare := c.PrepareCurrentCard
if next {
prepare = c.PrepareNextCard
}
if _, err := prepare(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"AP", "FC0", "AP", "AP", "AP", "FC7", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("incident commands=%v, want %v", got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != 2*time.Second {
t.Errorf("clearance waits=%s, want 2s", elapsed)
}
})
}
}
func TestDeliveryClearWaitsTwoSeconds(t *testing.T) {
for _, position := range []byte{0x30, 0x34} {
p := &scriptedTransport{chunks: [][]byte{vendorACK,
vendorACK, apReply(status(position)), vendorACK, apReply(status(position)), vendorACK, apReply(status(position)),
vendorACK, vendorACK, apReply(status(0x37))}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"FC0", "AP", "AP", "AP", "FC7", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("clearance %X commands=%v, want %v", position, got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != deliveryMinimumWait {
t.Errorf("clearance %X elapsed=%s, want 2s", position, elapsed)
}
}
}
func TestDeliveryPendingIsNotPersisted(t *testing.T) {
c := NewClient(nil, 1)
defer c.Close()
if c.deliveryPending {
t.Error("new Client has pending delivery")
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x37))}}
fresh, _ := deliveryWorkerClient(t, p)
if _, err := fresh.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("fresh client encoder status: %v", err)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("restart commands=%v, want AP only", got)
}
}
func TestDeliveryCancellationAfterACKDoesNotSetPending(t *testing.T) {
transportAddress(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK}, afterRead: cancel}
c := &Client{port: p}
r := workerRequest(c, ctx, cmdOutOfMouth)
if !errors.Is(r.err, context.Canceled) || c.deliveryPending || len(p.writes) != 1 {
t.Errorf("cancel after ACK: err=%v pending=%v writes=%d, want canceled, false, 1", r.err, c.deliveryPending, len(p.writes))
}
}
func TestWorkerSerializesClearanceAndFC7(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK, vendorACK, vendorACK, apReply(status(0x37))}}
c, _ := deliveryWorkerClient(t, p)
// Set initial state before any request can reach the worker.
c.deliveryPending = true
c.deliveryStarted = c.now().Add(-deliveryMinimumWait)
reading := make(chan struct{})
resume := make(chan struct{})
p.afterWrite = func() {
if len(p.writes) == 1 {
close(reading)
<-resume
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fc7 := make(chan error, 1)
go func() { fc7 <- c.BeginPrepareNextCard(ctx) }()
select {
case <-reading:
case <-ctx.Done():
t.Fatal("worker did not begin AP")
}
fc0 := make(chan cmdResp, 1)
// This FC0 is queued while AP is in progress. It must not slip between
// the clearance observation and FC7 dispatch.
c.reqCh <- cmdReq{typ: cmdOutOfMouth, ctx: ctx, respCh: fc0}
close(resume)
if err := <-fc7; err != nil {
t.Fatal(err)
}
if r := <-fc0; r.err != nil {
t.Fatal(r.err)
}
want := []string{"AP", "FC7", "FC0"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("serialized commands=%v, want %v", got, want)
}
}
func TestDeliveryCancellationAfterFreshAPKeepsPending(t *testing.T) {
transportAddress(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x34))}}
p.afterRead = func() {
if len(p.chunks) == 0 {
cancel()
}
}
c := &Client{port: p, deliveryPending: true, deliveryStarted: time.Now().Add(-time.Minute)}
r := workerRequest(c, ctx, cmdToEncoder)
if !errors.Is(r.err, context.Canceled) || !c.deliveryPending {
t.Errorf("AP cancellation err=%v pending=%t, want canceled and pending", r.err, c.deliveryPending)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("cancelled clearance commands=%v, want AP only", got)
}
}
func TestPreparationWireFailuresNeverShake(t *testing.T) {
badBCC := apReply(status(0x30))
badBCC[len(badBCC)-1] ^= 1
for _, frame := range [][]byte{nil, apReply(status(0x30))[:8], badBCC, apReply(status(0x40))} {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK, vendorACK}}
if frame != nil {
p.chunks = append(p.chunks, frame)
}
c, _ := deliveryWorkerClient(t, p)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("frame % X: err=%v, want encoder opportunity", frame, err)
}
want := []string{"AP", "FC7", "AP", "AP", "AP", "AP", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("frame % X commands=%v, want %v without RS or resends", frame, got, want)
}
}
}
func TestBeginPrepareNextCardWaitsForClearanceWithoutReadinessPolling(t *testing.T) {
for _, position := range []byte{0x30, 0x34} {
p := &scriptedTransport{chunks: [][]byte{vendorACK,
vendorACK, apReply(status(position)), vendorACK, apReply(status(position)), vendorACK, apReply(status(position)),
vendorACK}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if err := c.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"FC0", "AP", "AP", "AP", "FC7"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("BeginPrepareNextCard(%X) commands = %v, want %v", position, got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != deliveryMinimumWait {
t.Errorf("BeginPrepareNextCard(%X) waited %s, want %s", position, elapsed, deliveryMinimumWait)
}
}
}

View File

@ -1,8 +1,11 @@
package dispenser
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"time"
@ -27,14 +30,25 @@ const (
CardWellEmptyMessage = "Card well is empty"
)
const (
positionPreDispense = 0x01
positionEncoder = 0x02
positionMouth = 0x04
positionEmpty = 0x08
)
var (
ErrCardWellEmpty = errors.New(CardWellEmptyMessage)
// ErrPreparationExhausted permits a new, independent UI preparation attempt.
ErrPreparationExhausted = errors.New("card preparation exhausted")
SerialPort string
Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7"
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
commandRS = []byte{0x02, 0x52, 0x53} // Length 2, "RS"
statusPos0 = map[byte]string{
0x38: "Keep",
@ -58,20 +72,21 @@ var (
0x31: "Card pre-empty",
0x30: "Normal",
}
statusPos3 = map[byte]string{
0x38: "Card empty",
0x34: "Card ready position",
0x33: "Card at encoder position",
0x32: "Card at hold card position",
0x31: "Card out of card mouth position",
0x30: "Normal",
}
)
// --------------------
// Status helpers
// --------------------
// decodePositionStatus decodes the manual's 0x30 + combined sensor/empty flags.
// Sensor 2 (0x02) is the read position; sensor 1 and sensor 2 together give 0x33.
func decodePositionStatus(value byte) (flags byte, valid bool) {
if value&0xF0 != 0x30 {
return 0, false
}
return value & 0x0F, true
}
func statusDescription(statusBytes []byte) string {
if len(statusBytes) < 4 {
return fmt.Sprintf("<invalid len=%d>", len(statusBytes))
@ -85,7 +100,6 @@ func statusDescription(statusBytes []byte) string {
{pos: 1, value: statusBytes[0], mapper: statusPos0},
{pos: 2, value: statusBytes[1], mapper: statusPos1},
{pos: 3, value: statusBytes[2], mapper: statusPos2},
{pos: 4, value: statusBytes[3], mapper: statusPos3},
}
var result strings.Builder
@ -98,6 +112,24 @@ func statusDescription(statusBytes []byte) string {
result.WriteString(statusMsg + "; ")
}
}
flags, valid := decodePositionStatus(statusBytes[3])
if !valid {
fmt.Fprintf(&result, "Unknown status 0x%X at position 4; ", statusBytes[3])
} else {
for _, position := range []struct {
mask byte
text string
}{
{positionPreDispense, "Card at pre-dispense position"},
{positionEncoder, "Card at encoder position"},
{positionMouth, "Card at mouth position"},
{positionEmpty, "Card empty"},
} {
if flags&position.mask != 0 {
result.WriteString(position.text + "; ")
}
}
}
return result.String()
}
@ -106,38 +138,42 @@ func logStatus(statusBytes []byte) {
}
func isAtEncoderPosition(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
if len(statusBytes) < 4 {
return false
}
flags, valid := decodePositionStatus(statusBytes[3])
return valid && flags&positionEncoder != 0
}
func validateDispenserStatusData(statusBytes []byte) error {
if len(statusBytes) != 4 {
return fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(statusBytes))
}
type preparationClass string
statusMaps := []map[byte]string{statusPos0, statusPos1, statusPos2, statusPos3}
for position, mapper := range statusMaps {
if _, ok := mapper[statusBytes[position]]; !ok {
return fmt.Errorf("unknown dispenser status 0x%X at position %d", statusBytes[position], position+1)
}
}
return nil
}
const (
encoderConfirmed preparationClass = "encoder confirmed"
positionUncertain preparationClass = "valid but uncertain"
positionWellEmpty preparationClass = "empty"
positionNoCard preparationClass = "no card on sensors"
)
func dispenserStatusError(statusBytes []byte) error {
switch statusBytes[0] {
case 0x34, 0x32, 0x36:
return fmt.Errorf("dispenser error: %s", statusPos0[statusBytes[0]])
// classifyPreparationStatus is the only readiness gate after AP wire validation.
// Diagnostics describe the device; they do not veto a physical position class.
func classifyPreparationStatus(status []byte) (preparationClass, error) {
if len(status) != 4 {
return "", fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(status))
}
switch statusBytes[1] {
case 0x32, 0x31:
return fmt.Errorf("dispenser error: %s", statusPos1[statusBytes[1]])
flags, valid := decodePositionStatus(status[3])
if !valid {
return "", fmt.Errorf("malformed dispenser position encoding: 0x%X", status[3])
}
switch statusBytes[2] {
case 0x34, 0x32:
return fmt.Errorf("dispenser error: %s", statusPos2[statusBytes[2]])
switch {
case flags&positionEncoder != 0:
return encoderConfirmed, nil
case status[3] == 0x38:
return positionWellEmpty, nil
case status[3] == 0x30:
return positionNoCard, nil
default:
return positionUncertain, nil
}
return nil
}
func isPreparationMoving(statusBytes []byte) bool {
@ -145,11 +181,6 @@ func isPreparationMoving(statusBytes []byte) bool {
(statusBytes[0] == 0x31 || statusBytes[1] == 0x38)
}
func hasPreparationDiagnostics(statusBytes []byte) bool {
return len(statusBytes) == 4 &&
(statusBytes[0] != 0x30 || statusBytes[1] != 0x30 || statusBytes[2] != 0x30)
}
func stockTake(statusBytes []byte) string {
if len(statusBytes) < 4 {
return ""
@ -161,14 +192,18 @@ func stockTake(statusBytes []byte) string {
if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]]
}
if statusBytes[3] == 0x38 {
status = statusPos3[statusBytes[3]]
if isCardWellEmpty(statusBytes) {
status = "Card empty"
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x38
if len(statusBytes) < 4 {
return false
}
flags, valid := decodePositionStatus(statusBytes[3])
return valid && flags&positionEmpty != 0
}
func checkACK(statusResp []byte) error {
@ -207,20 +242,105 @@ func createPacket(address []byte, command []byte) []byte {
func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) }
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
_, err := port.Write(packet)
if err != nil {
return nil, fmt.Errorf("error writing to port: %w", err)
// serialTransport is used only by the serial-port owner.
type serialTransport interface {
io.Reader
io.Writer
}
time.Sleep(delay)
buf := make([]byte, 128)
n, err := port.Read(buf)
if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err)
func writePacket(ctx context.Context, port serialTransport, packet []byte) error {
_, err := writePacketAttempt(ctx, port, packet)
return err
}
return buf[:n], nil
// writePacketAttempt distinguishes cancellation before Write from an ambiguous write.
func writePacketAttempt(ctx context.Context, port serialTransport, packet []byte) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
n, err := port.Write(packet)
if err != nil {
return true, fmt.Errorf("write dispenser packet: %w", err)
}
if n != len(packet) {
return true, fmt.Errorf("write dispenser packet (%d/%d bytes): %w", n, len(packet), io.ErrShortWrite)
}
return true, ctx.Err()
}
func readExact(ctx context.Context, port serialTransport, data []byte) error {
for len(data) > 0 {
if err := ctx.Err(); err != nil {
return err
}
n, err := port.Read(data)
data = data[n:]
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
return fmt.Errorf("read dispenser response: %w", err)
}
if n == 0 {
return fmt.Errorf("read dispenser response: %w", io.ErrNoProgress)
}
}
return nil
}
func sendAndReadACK(ctx context.Context, port serialTransport, packet []byte, processingDelay time.Duration) error {
if err := writePacket(ctx, port, packet); err != nil {
return err
}
if err := waitForSequence(ctx, processingDelay); err != nil {
return err
}
response := make([]byte, 3)
if err := readExact(ctx, port, response); err != nil {
return fmt.Errorf("read ACK: %w", err)
}
return checkACK(response)
}
// queryStatus accepts only the fixed RF/AP payload sizes, before reading a body.
func queryStatus(ctx context.Context, port serialTransport, command []byte, statusCount int, processingDelay time.Duration) ([]byte, error) {
if err := sendAndReadACK(ctx, port, createPacket(Address, command), processingDelay); err != nil {
return nil, err
}
if err := writePacket(ctx, port, append([]byte{ENQ}, Address...)); err != nil {
return nil, err
}
if err := waitForSequence(ctx, processingDelay); err != nil {
return nil, err
}
header := make([]byte, 5)
if err := readExact(ctx, port, header); err != nil {
return nil, fmt.Errorf("read status header: %w", err)
}
if header[0] != STX {
return nil, fmt.Errorf("invalid status STX: % X", header)
}
if len(Address) != 2 || header[1] != Address[0] || header[2] != Address[1] {
return nil, fmt.Errorf("unexpected status address: % X", header[1:3])
}
length := int(binary.BigEndian.Uint16(header[3:5]))
if length != statusCount+2 {
return nil, fmt.Errorf("invalid status payload length: got %d, want %d", length, statusCount+2)
}
frame := append(header, make([]byte, length+2)...)
if err := readExact(ctx, port, frame[5:]); err != nil {
return nil, fmt.Errorf("read status body: %w", err)
}
if frame[len(frame)-2] != ETX {
return nil, fmt.Errorf("invalid status ETX: % X", frame)
}
if calculateBCC(frame[:len(frame)-1]) != frame[len(frame)-1] {
return nil, fmt.Errorf("invalid status BCC: % X", frame)
}
if frame[5] != 'S' || frame[6] != 'F' {
return nil, fmt.Errorf("unexpected status response type: % X", frame[5:7])
}
return frame[7 : 7+statusCount], nil
}
// --------------------
@ -270,69 +390,31 @@ func InitializeDispenser() (*serial.Port, error) {
// --------------------
// checkDispenserStatus talks to the device and returns the 4 status bytes [pos0..pos3].
func checkDispenserStatus(port *serial.Port) ([]byte, error) {
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return nil, fmt.Errorf("error sending check command: %w", err)
}
if len(statusResp) == 0 {
return nil, fmt.Errorf("no response from dispenser")
}
if err := checkACK(statusResp); err != nil {
return nil, err
func checkDispenserStatus(ctx context.Context, port serialTransport) ([]byte, error) {
return queryStatus(ctx, port, []byte{0x02, 'A', 'P'}, 4, delay)
}
statusResp, err = sendAndReceive(port, enq, delay)
if err != nil {
return nil, fmt.Errorf("error sending ENQ: %w", err)
// dispatchCommand confirms ACK and sends ENQ; it does not wait for movement.
func dispatchCommand(ctx context.Context, port serialTransport, command []byte, processingDelay time.Duration) error {
if err := sendAndReadACK(ctx, port, createPacket(Address, command), processingDelay); err != nil {
return err
}
if len(statusResp) < 13 {
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
}
return statusResp[7:11], nil
return writePacket(ctx, port, append([]byte{ENQ}, Address...))
}
func cardToEncoderPosition(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
dispenseCmd := createPacket(Address, commandFC7)
func cardToEncoderPosition(ctx context.Context, port serialTransport) error {
log.Println("Send card to encoder position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return fmt.Errorf("error sending card to encoder position: %w", err)
}
if err := checkACK(statusResp); err != nil {
return err
return dispatchCommand(ctx, port, commandFC7, delay)
}
_, err = port.Write(enq)
if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
}
return nil
func resetDispenser(ctx context.Context, port serialTransport) error {
return dispatchCommand(ctx, port, commandRS, delay)
}
func cardOutOfMouth(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
dispenseCmd := createPacket(Address, commandFC0)
func cardOutOfMouth(ctx context.Context, port serialTransport) (bool, error) {
log.Println("Send card to out mouth position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return fmt.Errorf("error sending out of mouth command: %w", err)
if err := sendAndReadACK(ctx, port, createPacket(Address, commandFC0), delay); err != nil {
return false, err
}
if err := checkACK(statusResp); err != nil {
return err
}
_, err = port.Write(enq)
if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
}
return nil
return writePacketAttempt(ctx, port, append([]byte{ENQ}, Address...))
}

View File

@ -3,6 +3,7 @@ package dispenser
import (
"context"
"errors"
"fmt"
"sync"
"time"
@ -17,6 +18,8 @@ const (
cmdStatus cmdType = iota
cmdToEncoder
cmdOutOfMouth
cmdReset
cmdDeliveryClearance
)
type cmdReq struct {
@ -26,6 +29,7 @@ type cmdReq struct {
}
type cmdResp struct {
deliveryPending bool
status []byte
err error
}
@ -37,16 +41,25 @@ type sequenceTiming struct {
const (
sequencePollInterval = time.Second
sequenceRetryAfter = 6 * time.Second
sequenceTimeout = 12 * time.Second
sequenceShakeAfter = 3 * time.Second
sequenceResetWait = 2 * time.Second
sequenceTimeout = 32 * time.Second
sequenceUncertainWait = 4 * time.Second
sequenceMaxShakes = 3
deliveryClearanceTimeout = 6 * time.Second
deliveryMinimumWait = 2 * time.Second
)
type Client struct {
port *serial.Port
port serialTransport
reqCh chan cmdReq
done chan struct{}
// Owned exclusively by the serial worker.
deliveryPending bool
deliveryStarted time.Time
sequenceTiming sequenceTiming
// status cache
@ -187,26 +200,39 @@ func (c *Client) handle(req cmdReq) {
switch req.typ {
case cmdStatus:
st, err := checkDispenserStatus(c.port)
if err == nil && len(st) == 4 {
c.mu.Lock()
c.lastStatus = append([]byte(nil), st...)
c.lastStatusT = time.Now()
c.mu.Unlock()
st, err := c.readWorkerStatus(req.ctx)
req.respCh <- cmdResp{status: st, err: err, deliveryPending: c.deliveryPending}
// publish stock/cardwell
c.setStock(st)
}
req.respCh <- cmdResp{status: st, err: err}
case cmdDeliveryClearance:
st, err := c.waitWorkerDeliveryClearance(req.ctx)
req.respCh <- cmdResp{status: st, err: err, deliveryPending: c.deliveryPending}
case cmdToEncoder:
err := cardToEncoderPosition(c.port)
// A movement command makes any previously cached position unreliable.
if c.deliveryPending {
if _, err := c.waitWorkerDeliveryClearance(req.ctx); err != nil {
req.respCh <- cmdResp{err: err}
return
}
}
err := cardToEncoderPosition(req.ctx, c.port)
log.Infof("FC7 dispatch finished; dispatched=%t error=%v", err == nil, err)
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
case cmdReset:
err := resetDispenser(req.ctx, c.port)
log.Infof("RS dispatch finished; dispatched=%t error=%v", err == nil, err)
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
case cmdOutOfMouth:
err := cardOutOfMouth(c.port)
attempted, err := cardOutOfMouth(req.ctx, c.port)
log.Infof("FC0 dispatch finished; ENQ_attempted=%t error=%v", attempted, err)
if attempted {
c.deliveryPending = true
c.deliveryStarted = c.now()
log.Info("delivery ENQ attempted; awaiting mechanical clearance")
}
// A movement command makes any previously cached position unreliable.
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
@ -216,21 +242,78 @@ func (c *Client) handle(req cmdReq) {
}
}
// deliveryClearance deliberately does not apply encoder-success precedence.
func deliveryClearance(status []byte) (bool, error) {
class, err := classifyPreparationStatus(status)
if err != nil {
return false, err
}
if class == positionWellEmpty {
return false, ErrCardWellEmpty
}
if isPreparationMoving(status) || status[1] == 0x34 {
return false, nil
}
return status[3] == 0x30 || status[3] == 0x34, nil
}
func (c *Client) now() time.Time {
if c.sequenceTiming.now != nil {
return c.sequenceTiming.now()
}
return time.Now()
}
// readWorkerStatus is called only by the serial worker and always reads fresh AP.
func (c *Client) readWorkerStatus(ctx context.Context) ([]byte, error) {
st, err := checkDispenserStatus(ctx, c.port)
if err != nil {
return st, err
}
if err := ctx.Err(); err != nil {
return st, err
}
if c.deliveryPending {
class, _ := classifyPreparationStatus(st)
log.Infof("delivery clearance AP; class=%s elapsed=%s raw status: % X", class, c.now().Sub(c.deliveryStarted), st)
clear, clearanceErr := deliveryClearance(st)
if clearanceErr != nil {
log.Warnf("delivery clearance failed: %v; %s raw status: % X", clearanceErr, statusDescription(st), st)
} else if clear && c.now().Sub(c.deliveryStarted) >= deliveryMinimumWait {
c.deliveryPending = false
log.Infof("previous delivery cleared after %s; FC7 permitted", c.now().Sub(c.deliveryStarted))
}
}
if len(st) == 4 {
c.mu.Lock()
c.lastStatus = append([]byte(nil), st...)
c.lastStatusT = time.Now()
c.mu.Unlock()
c.setStock(st)
}
return st, nil
}
func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) {
r := c.doResponse(ctx, typ)
return r.status, r.err
}
func (c *Client) doResponse(ctx context.Context, typ cmdType) cmdResp {
rch := make(chan cmdResp, 1)
req := cmdReq{typ: typ, ctx: ctx, respCh: rch}
select {
case c.reqCh <- req:
case <-ctx.Done():
return nil, ctx.Err()
return cmdResp{err: ctx.Err()}
}
select {
case r := <-rch:
return r.status, r.err
return r
case <-ctx.Done():
return nil, ctx.Err()
return cmdResp{err: ctx.Err()}
}
}
@ -262,6 +345,12 @@ func (c *Client) ToEncoder(ctx context.Context) error {
return err
}
// Reset dispatches RS through the port owner; acceptance does not imply mechanical completion.
func (c *Client) Reset(ctx context.Context) error {
_, err := c.do(ctx, cmdReset)
return err
}
func (c *Client) OutOfMouth(ctx context.Context) error {
_, err := c.do(ctx, cmdOutOfMouth)
return err
@ -309,13 +398,14 @@ func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
}
func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]byte, string, error) {
status, err := c.do(ctx, cmdStatus)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, "", fmt.Errorf("[%s] read status: %w", operation, ctxErr)
response := c.doResponse(ctx, cmdStatus)
if err := ctx.Err(); err != nil {
return nil, "", err
}
return nil, "", fmt.Errorf("[%s] read status: %w", operation, err)
if response.deliveryPending {
return c.waitForDeliveryClearance(ctx, operation)
}
status := usableObservation(response.status, response.err, operation)
stockStatus := ""
if len(status) == 4 {
@ -326,132 +416,236 @@ func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]by
return status, stockStatus, nil
}
func preparationStatus(operation string, status []byte) (bool, error) {
if len(status) != 4 {
return false, fmt.Errorf("[%s] %w", operation, validateDispenserStatusData(status))
}
if isAtEncoderPosition(status) {
if hasPreparationDiagnostics(status) {
log.Warnf(
"[%s] card confirmed at encoder with dispenser diagnostics: %s raw status: % X",
operation,
statusDescription(status),
status,
)
}
return true, nil
}
if err := validateDispenserStatusData(status); err != nil {
return false, fmt.Errorf("[%s] %w", operation, err)
}
if isCardWellEmpty(status) {
return false, fmt.Errorf("[%s] %w", operation, ErrCardWellEmpty)
}
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)
}
return false, nil
}
func (c *Client) pollForEncoderPosition(
ctx context.Context,
operation string,
retryCommand func(context.Context) error,
) (string, error) {
started := c.sequenceTiming.now()
halfway := started.Add(sequenceRetryAfter)
deadline := started.Add(sequenceTimeout)
retried := false
stockStatus := ""
// waitWorkerDeliveryClearance runs only inside the serial worker.
// An unusable observation never becomes a fabricated physical position.
func (c *Client) waitWorkerDeliveryClearance(parent context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(parent, deliveryClearanceTimeout)
defer cancel()
deadline := c.now().Add(deliveryClearanceTimeout)
var latest []byte
for {
if err := ctx.Err(); err != nil {
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
if err := parent.Err(); err != nil {
return nil, err
}
now := c.sequenceTiming.now()
if !now.Before(deadline) {
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
if !c.now().Before(deadline) || ctx.Err() != nil {
class, err := classifyPreparationStatus(latest)
if err == nil && class == positionWellEmpty {
return latest, ErrCardWellEmpty
}
status, currentStockStatus, err := c.readSequenceStatus(ctx, operation)
stockStatus = currentStockStatus
if err != nil {
return stockStatus, err
if err == nil && (isPreparationMoving(latest) || latest[1] == 0x34) {
return latest, context.DeadlineExceeded
}
ready, err := preparationStatus(operation, status)
if err != nil {
return stockStatus, err
c.deliveryPending = false
log.Warn("delivery clearance assumed after bounded observation fallback")
return latest, nil
}
if ready {
return stockStatus, nil
st, err := c.readWorkerStatus(ctx)
if parent.Err() != nil {
return nil, parent.Err()
}
now = c.sequenceTiming.now()
if !now.Before(deadline) {
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
latest = usableObservation(st, err, "delivery clearance")
if latest != nil && latest[3] == 0x38 {
return latest, ErrCardWellEmpty
}
if retryCommand != nil && !retried && !now.Before(halfway) {
if err := retryCommand(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] retry command: %w", operation, err)
if !c.deliveryPending {
return latest, nil
}
retried = true
remaining := deadline.Sub(c.now())
if remaining <= 0 || ctx.Err() != nil {
continue
}
wait := sequencePollInterval
if remaining := deadline.Sub(now); remaining < wait {
if remaining < wait {
wait = remaining
}
if err := c.sequenceTiming.wait(ctx, wait); err != nil {
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
if err := c.sequenceTiming.wait(ctx, wait); err != nil && parent.Err() != nil {
return nil, parent.Err()
}
}
}
func (c *Client) prepareCardAtEncoder(ctx context.Context, operation string) (string, error) {
status, stockStatus, err := c.readSequenceStatus(ctx, operation)
// usableObservation preserves strict validation while discarding unusable telemetry.
func usableObservation(status []byte, err error, operation string) []byte {
if err == nil {
_, err = classifyPreparationStatus(status)
}
if err != nil {
return stockStatus, err
log.Warnf("[%s] unusable AP observation; raw status: % X error=%v", operation, status, err)
return nil
}
ready, err := preparationStatus(operation, status)
return status
}
// waitForDeliveryClearance uses a worker request; no worker recursively enqueues.
func (c *Client) waitForDeliveryClearance(ctx context.Context, operation string) ([]byte, string, error) {
r := c.doResponse(ctx, cmdDeliveryClearance)
stock := ""
if len(r.status) == 4 {
stock = stockTake(r.status)
}
if r.err != nil {
return nil, stock, fmt.Errorf("[%s] delivery clearance: %w", operation, r.err)
}
return r.status, stock, nil
}
func (c *Client) prepareCardAtEncoder(parent context.Context, operation string) (stock string, resultErr error) {
status, stock, err := c.waitForDeliveryClearance(parent, operation)
if err != nil {
return stockStatus, err
return stock, err
}
status = usableObservation(status, nil, operation)
started := c.now()
deadline := started.Add(sequenceTimeout)
ctx, cancel := context.WithTimeoutCause(parent, sequenceTimeout, ErrPreparationExhausted)
defer cancel()
shakes := 0
stage := "initial status"
var lastFC7, uncertainSince time.Time
var previous preparationClass
defer func() {
log.Infof("[%s] preparation finished; stage=%s shakes=%d elapsed=%s raw status: % X error=%v", operation, stage, shakes, c.now().Sub(started), status, resultErr)
}()
checkDeadline := func() error {
if err := parent.Err(); err != nil {
return err
}
if !c.now().Before(deadline) || errors.Is(context.Cause(ctx), ErrPreparationExhausted) {
return ErrPreparationExhausted
}
return ctx.Err()
}
// Only observation exhaustion can grant a deadline handoff. Command and
// reset-settle failures never pass through this policy.
observationResult := func(err error) error {
if parent.Err() != nil {
return parent.Err()
}
if !errors.Is(err, ErrPreparationExhausted) || lastFC7.IsZero() {
return err
}
class, _ := classifyPreparationStatus(status)
switch class {
case positionWellEmpty:
return ErrCardWellEmpty
case positionNoCard:
return err
default:
stage = "observation deadline encoder handoff"
return nil
}
}
wait := func(duration time.Duration) error {
if err := checkDeadline(); err != nil {
return err
}
if remaining := deadline.Sub(c.now()); remaining < duration {
duration = remaining
}
if err := c.sequenceTiming.wait(ctx, duration); err != nil {
if deadlineErr := checkDeadline(); deadlineErr != nil {
return deadlineErr
}
return err
}
return checkDeadline()
}
sendFC7 := func() error {
stage = "FC7 dispatch"
if err := checkDeadline(); err != nil {
return err
}
err := c.ToEncoder(ctx)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return deadlineErr
}
if err != nil {
return fmt.Errorf("[%s] FC7 dispatch: %w", operation, err)
}
lastFC7 = c.now()
log.Infof("[%s] FC7 dispatched; shake=%d elapsed=%s", operation, shakes, lastFC7.Sub(started))
return nil
}
for {
if err := checkDeadline(); err != nil {
return stock, observationResult(err)
}
class, _ := classifyPreparationStatus(status)
// No class means unusable telemetry, sharing the uncertainty timer.
log.Infof("[%s] fresh AP; class=%s previous=%s elapsed=%s shake=%d raw status: % X", operation, class, previous, c.now().Sub(started), shakes, status)
previous = class
switch class {
case encoderConfirmed:
stage = "encoder sensor handoff"
return stock, observationResult(checkDeadline())
case positionWellEmpty:
stage = "empty"
return stock, ErrCardWellEmpty
}
if lastFC7.IsZero() {
if err := sendFC7(); err != nil {
return stock, err
}
} else if class == positionNoCard && c.now().Sub(lastFC7) >= sequenceShakeAfter {
uncertainSince = time.Time{}
if shakes == sequenceMaxShakes {
stage = "three shakes exhausted"
return stock, ErrPreparationExhausted
}
shakes++
stage = "RS dispatch"
if err := checkDeadline(); err != nil {
return stock, err
}
err := c.Reset(ctx)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return stock, deadlineErr
}
if err != nil {
return stock, fmt.Errorf("[%s] RS dispatch: %w", operation, err)
}
stage = "reset settling"
log.Infof("[%s] RS dispatched; shake=%d settling=%s", operation, shakes, sequenceResetWait)
if err := wait(sequenceResetWait); err != nil {
return stock, err
}
log.Infof("[%s] reset settle wait completed; shake=%d", operation, shakes)
if err := sendFC7(); err != nil {
return stock, err
}
} else {
if class == positionUncertain || class == "" {
if uncertainSince.IsZero() {
uncertainSince = c.now()
}
if c.now().Sub(uncertainSince) >= sequenceUncertainWait {
stage = "uncertain position handoff"
return stock, observationResult(checkDeadline())
}
} else {
uncertainSince = time.Time{}
}
stage = "polling"
if err := wait(sequencePollInterval); err != nil {
return stock, observationResult(err)
}
}
stage = "fresh AP"
if err := checkDeadline(); err != nil {
return stock, observationResult(err)
}
status, stock, err = c.readSequenceStatus(ctx, operation)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return stock, observationResult(deadlineErr)
}
if err != nil {
return stock, err
}
}
if ready {
return stockStatus, nil
}
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", operation, err)
}
return c.pollForEncoderPosition(ctx, operation, c.ToEncoder)
}
// PrepareCurrentCard authoritatively places the card to be encoded at the encoder.
// PrepareCurrentCard grants one encoder opportunity after bounded physical preparation.
func (c *Client) PrepareCurrentCard(ctx context.Context) (string, error) {
return c.prepareCardAtEncoder(ctx, "PrepareCurrentCard")
}
@ -466,7 +660,8 @@ func (c *Client) DeliverCurrentCard(ctx context.Context) (string, error) {
return "", nil
}
// BeginPrepareNextCard starts moving the next card to the encoder without waiting for readiness.
// BeginPrepareNextCard waits for worker-owned clearance and dispatches one FC7.
// It does not wait for encoder readiness.
func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
if err := c.ToEncoder(ctx); err != nil {
return fmt.Errorf("[BeginPrepareNextCard] to encoder: %w", err)
@ -474,7 +669,7 @@ func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
return nil
}
// PrepareNextCard places a new card at the encoder for a later issuance attempt.
// PrepareNextCard runs the same bounded physical preparation for a later issuance attempt.
func (c *Client) PrepareNextCard(ctx context.Context) (string, error) {
return c.prepareCardAtEncoder(ctx, "PrepareNextCard")
}

View File

@ -4,17 +4,19 @@ import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
)
type fakeSequenceDevice struct {
statusResponses []cmdResp
commandErrors map[cmdType][]error
commands []cmdType
commandTimes []time.Time
onCommand func(cmdType)
}
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
@ -50,13 +52,25 @@ func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *
case <-client.done:
return
case request := <-client.reqCh:
clearance := request.typ == cmdDeliveryClearance
if clearance {
request.typ = cmdStatus
}
device.commands = append(device.commands, request.typ)
device.commandTimes = append(device.commandTimes, now)
if device.onCommand != nil {
device.onCommand(request.typ)
}
if request.typ == cmdStatus {
if len(device.statusResponses) == 0 {
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
continue
}
response := device.statusResponses[0]
if clearance && request.ctx.Err() == nil {
response.status = usableObservation(response.status, response.err, "fake clearance")
response.err = nil
}
device.statusResponses = device.statusResponses[1:]
request.respCh <- response
continue
@ -90,176 +104,11 @@ func commandCount(commands []cmdType, target cmdType) int {
return count
}
func TestPrepareCurrentCardAcceptsEncoderPositionWithStaleDiagnostics(t *testing.T) {
tests := []struct {
name string
status []byte
wantStock string
}{
{
name: "dispense error and jam",
status: []byte{0x30, 0x32, 0x32, 0x33},
wantStock: "Card jammed",
},
{
name: "supply diagnostics",
status: []byte{0x32, 0x30, 0x31, 0x33},
wantStock: "Card pre-empty",
},
{
name: "combined stale jam and supply diagnostics",
status: []byte{0x30, 0x32, 0x33, 0x33},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var logOutput bytes.Buffer
standardLogger := log.StandardLogger()
previousOutput := standardLogger.Out
standardLogger.SetOutput(&logOutput)
t.Cleanup(func() {
standardLogger.SetOutput(previousOutput)
})
client, device := newSequenceTestClient(t, cmdResp{status: test.status})
stock, err := client.PrepareCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != test.wantStock {
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
}
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
t.Fatalf("to-encoder commands = %d, want 0", got)
}
if got := commandCount(device.commands, cmdStatus); got != 1 {
t.Fatalf("status reads = %d, want 1", got)
}
logged := logOutput.String()
if !strings.Contains(logged, "card confirmed at encoder") ||
!strings.Contains(logged, statusDescription(test.status)) ||
!strings.Contains(logged, "raw status") {
t.Fatalf("encoder diagnostic log = %q", logged)
}
})
}
}
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)},
cmdResp{status: []byte{0x31, 0x38, 0x32, 0x30}},
cmdResp{status: []byte{0x30, 0x32, 0x32, 0x33}},
)
stock, err := client.PrepareCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != "Card jammed" {
t.Fatalf("stock status = %q, want Card jammed", 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 != 3 {
t.Fatalf("status reads = %d, want 3", got)
}
}
func TestPrepareCurrentCardRejectsFailureWithoutEncoderOrMovement(t *testing.T) {
tests := []struct {
name string
response cmdResp
wantError string
wantStock string
}{
{
name: "status read error",
response: cmdResp{err: errors.New("serial read failed")},
wantError: "serial read failed",
},
{
name: "malformed status",
response: cmdResp{status: []byte{0x30, 0x30, 0x31}},
wantError: "malformed dispenser status",
},
{
name: "unknown status",
response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x39}},
wantError: "unknown dispenser status",
},
{
name: "jammed preparation",
response: cmdResp{status: []byte{0x30, 0x30, 0x32, 0x34}},
wantError: "Card jammed",
wantStock: "Card jammed",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, _ := newSequenceTestClient(t, test.response)
stock, err := client.PrepareCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want containing %q", err, test.wantError)
}
if stock != test.wantStock {
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
}
})
}
}
func TestPrepareCurrentCardReturnsAuthoritativeEmptyWellError(t *testing.T) {
client, device := newSequenceTestClient(t, cmdResp{status: status(0x38)})
stock, err := client.PrepareCurrentCard(context.Background())
if !errors.Is(err, ErrCardWellEmpty) {
t.Fatalf("error = %v, want ErrCardWellEmpty", err)
}
if stock != "Card empty" {
t.Fatalf("stock status = %q, want Card empty", stock)
}
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
t.Fatalf("to-encoder commands = %d, want 0", got)
func TestResetPacket(t *testing.T) {
got := createPacket([]byte{0x30, 0x30}, commandRS)
want := []byte{0x02, 0x30, 0x30, 0x00, 0x02, 0x52, 0x53, 0x03, 0x02}
if !bytes.Equal(got, want) {
t.Errorf("createPacket(RS) = % X, want % X", got, want)
}
}
@ -325,42 +174,6 @@ func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
}
}
func TestPrepareCurrentCardRetriesOnceThenTimesOut(t *testing.T) {
responses := make([]cmdResp, 13)
for index := range responses {
responses[index] = cmdResp{status: status(0x34)}
}
client, device := newSequenceTestClient(t, responses...)
_, err := client.PrepareCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("error = %v, want preparation timeout", err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 2 {
t.Fatalf("to-encoder commands = %d, want initial command plus one halfway retry", got)
}
if got := commandCount(device.commands, cmdStatus); got != 13 {
t.Fatalf("status reads = %d, want 13", got)
}
}
func TestPrepareCurrentCardPropagatesContextCancellation(t *testing.T) {
client, _ := newSequenceTestClient(t,
cmdResp{status: status(0x34)},
cmdResp{status: status(0x34)},
)
ctx, cancel := context.WithCancel(context.Background())
client.sequenceTiming.wait = func(context.Context, time.Duration) error {
cancel()
return ctx.Err()
}
_, err := client.PrepareCurrentCard(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
client, device := newSequenceTestClient(t)
@ -391,48 +204,292 @@ func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T)
}
}
func TestPrepareCardAtEncoderRequiresConfirmedEncoderState(t *testing.T) {
tests := []struct {
name string
responses []cmdResp
wantError string
wantToEncoder int
func TestPositionDescriptions(t *testing.T) {
for _, test := range []struct {
position byte
want string
}{
{
name: "already at encoder",
responses: []cmdResp{{status: status(0x33)}},
wantToEncoder: 0,
},
{
name: "moves ready card to encoder",
responses: []cmdResp{
{status: status(0x34)},
{status: status(0x33)},
},
wantToEncoder: 1,
},
{
name: "empty card well",
responses: []cmdResp{{status: status(0x38)}},
wantError: CardWellEmptyMessage,
wantToEncoder: 0,
},
{0x30, ""},
{0x31, "Card at pre-dispense position; "},
{0x32, "Card at encoder position; "},
{0x34, "Card at mouth position; "},
{0x37, "Card at pre-dispense position; Card at encoder position; Card at mouth position; "},
{0x39, "Card at pre-dispense position; Card empty; "},
{0x3F, "Card at pre-dispense position; Card at encoder position; Card at mouth position; Card empty; "},
{0x40, "Unknown status 0x40 at position 4; "},
} {
if got := statusDescription(status(test.position)); got != test.want {
t.Errorf("statusDescription(position %X) = %q, want %q", test.position, got, test.want)
}
}
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, device := newSequenceTestClient(t, test.responses...)
func positionResponses(position byte, count int) []cmdResp {
responses := make([]cmdResp, count)
for i := range responses {
responses[i] = cmdResp{status: status(position)}
}
return responses
}
_, err := client.PrepareNextCard(context.Background())
if test.wantError == "" && err != nil {
t.Fatal(err)
func TestPreparationClasses(t *testing.T) {
classes := map[preparationClass][]byte{
encoderConfirmed: {0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F},
positionUncertain: {0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D},
positionWellEmpty: {0x38}, positionNoCard: {0x30},
}
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
t.Fatalf("error = %v, want containing %q", err, test.wantError)
for want, positions := range classes {
for _, position := range positions {
st := []byte{0xFF, 0x36, 0x32, position}
if got, err := classifyPreparationStatus(st); got != want || err != nil {
t.Errorf("classify(% X)=%s,%v, want %s", st, got, err, want)
}
if got := commandCount(device.commands, cmdToEncoder); got != test.wantToEncoder {
t.Fatalf("to-encoder commands = %d, want %d", got, test.wantToEncoder)
}
}
for _, st := range [][]byte{nil, {0x30}, status(0x02), status(0x40), status(0xFF), {0x30, 0x30, 0x30, 0x37, 0x30}} {
if got, err := classifyPreparationStatus(st); err == nil {
t.Errorf("classify(% X)=%s,nil, want malformed error", st, got)
}
}
}
func TestPreparationAllPositionsBothMethods(t *testing.T) {
for _, next := range []bool{false, true} {
for position := byte(0x30); position <= 0x3F; position++ {
t.Run(fmt.Sprintf("next=%t/position=%X", next, position), func(t *testing.T) {
responses := positionResponses(position, 40)
for i := range responses {
responses[i].status[0], responses[i].status[1], responses[i].status[2] = 0xFF, 0x38, 0x34
}
c, d := newSequenceTestClient(t, responses...)
// Passive encoder data must never replace fresh AP.
c.lastStatus, c.lastStatusT, c.statusTTL = status(0x33), time.Now(), time.Hour
prepare := c.PrepareCurrentCard
if next {
prepare = c.PrepareNextCard
}
started := c.now()
_, err := prepare(context.Background())
wantFC7, wantRS, wantElapsed := 0, 0, time.Duration(0)
var wantErr error
switch position {
case 0x30:
wantFC7, wantRS, wantElapsed, wantErr = 4, 3, 18*time.Second, ErrPreparationExhausted
case 0x38:
wantErr = ErrCardWellEmpty
case 0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D:
wantFC7, wantElapsed = 1, 4*time.Second
}
if !errors.Is(err, wantErr) {
t.Errorf("prepare(%X) error=%v, want %v", position, err, wantErr)
}
if commandCount(d.commands, cmdToEncoder) != wantFC7 || commandCount(d.commands, cmdReset) != wantRS {
t.Errorf("prepare(%X) commands=%v, want FC7=%d RS=%d", position, d.commands, wantFC7, wantRS)
}
if got := c.now().Sub(started); got != wantElapsed {
t.Errorf("prepare(%X) elapsed=%s, want %s", position, got, wantElapsed)
}
})
}
}
}
func TestThreeShakesAreRequestLocalAndSettled(t *testing.T) {
c, d := newSequenceTestClient(t, positionResponses(0x30, 34)...)
for request := 0; request < 2; request++ {
start := len(d.commands)
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, ErrPreparationExhausted) {
t.Fatalf("request %d error=%v, want exhausted", request, err)
}
want := []cmdType{cmdStatus, cmdToEncoder}
for attempt := 0; attempt < 4; attempt++ {
want = append(want, cmdStatus, cmdStatus, cmdStatus, cmdStatus)
if attempt < 3 {
want = append(want, cmdReset, cmdToEncoder)
}
}
if !reflect.DeepEqual(d.commands[start:], want) {
t.Errorf("request %d commands=%v, want %v", request, d.commands[start:], want)
}
var lastFC7 time.Time
for i := start; i < len(d.commands); i++ {
if d.commands[i] == cmdReset && d.commandTimes[i].Sub(lastFC7) != 3*time.Second {
t.Errorf("RS at %s after FC7, want 3s", d.commandTimes[i].Sub(lastFC7))
}
if d.commands[i] == cmdToEncoder {
if i > start && d.commands[i-1] == cmdReset && d.commandTimes[i].Sub(d.commandTimes[i-1]) != 2*time.Second {
t.Errorf("FC7 settle=%s, want 2s", d.commandTimes[i].Sub(d.commandTimes[i-1]))
}
lastFC7 = d.commandTimes[i]
}
}
}
}
func TestPreparationReclassifiesEverySample(t *testing.T) {
for _, tc := range []struct {
name string
positions []byte
wantRS int
wantTime time.Duration
wantErr error
}{
{"uncertain changes do not restart timer", []byte{0x30, 0x31, 0x34, 0x35, 0x39, 0x3C}, 0, 4 * time.Second, nil},
{"uncertain becomes encoder", []byte{0x30, 0x34, 0x37}, 0, time.Second, nil},
{"uncertain becomes empty", []byte{0x30, 0x34, 0x38}, 0, time.Second, ErrCardWellEmpty},
{"no sensors becomes empty", []byte{0x30, 0x30, 0x38}, 0, time.Second, ErrCardWellEmpty},
{"uncertain returns to shake path twice", []byte{0x30, 0x34, 0x30, 0x30, 0x30, 0x34, 0x34, 0x30, 0x30, 0x37}, 2, 10 * time.Second, nil},
} {
t.Run(tc.name, func(t *testing.T) {
var responses []cmdResp
for _, p := range tc.positions {
responses = append(responses, cmdResp{status: status(p)})
}
c, d := newSequenceTestClient(t, responses...)
start := c.now()
_, err := c.PrepareCurrentCard(context.Background())
if !errors.Is(err, tc.wantErr) || c.now().Sub(start) != tc.wantTime || commandCount(d.commands, cmdReset) != tc.wantRS {
t.Errorf("prepare(%s) error=%v elapsed=%s commands=%v, want error=%v time=%s RS=%d", tc.name, err, c.now().Sub(start), d.commands, tc.wantErr, tc.wantTime, tc.wantRS)
}
})
}
}
func TestEveryShakeReclassifiesEncoderEmptyAndUncertain(t *testing.T) {
for shake := 1; shake <= 3; shake++ {
for _, position := range []byte{0x37, 0x38, 0x35} {
responses := positionResponses(0x30, 1+4*shake)
responses = append(responses, positionResponses(position, 5)...)
c, d := newSequenceTestClient(t, responses...)
_, err := c.PrepareCurrentCard(context.Background())
var wantErr error
if position == 0x38 {
wantErr = ErrCardWellEmpty
}
if !errors.Is(err, wantErr) || commandCount(d.commands, cmdReset) != shake || commandCount(d.commands, cmdToEncoder) != shake+1 {
t.Errorf("shake %d position=%X error=%v commands=%v, want %v and no further shake", shake, position, err, d.commands, wantErr)
}
}
}
}
func TestPreparationTransportAndDispatchFailuresDoNotShake(t *testing.T) {
failure := errors.New("serial failure")
for _, response := range []cmdResp{{err: failure}, {status: []byte{0x30}}, {status: status(0x40)}} {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, response)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("AP failure %v error=%v, want encoder opportunity", response, err)
}
if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 {
t.Errorf("AP failure commands=%v, want FC7 once and no RS", d.commands)
}
}
for _, command := range []cmdType{cmdToEncoder, cmdReset} {
c, d := newSequenceTestClient(t, positionResponses(0x30, 10)...)
d.commandErrors[command] = []error{failure}
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, failure) {
t.Errorf("command %v error=%v, want serial failure", command, err)
}
if commandCount(d.commands, command) != 1 {
t.Errorf("command %v retried: %v", command, d.commands)
}
}
}
func TestPreparationStopsAtCancellationAndDeadlineStages(t *testing.T) {
for _, stage := range []string{"initial AP", "FC7", "poll", "RS", "settle"} {
for _, callerCanceled := range []bool{false, true} {
if stage == "initial AP" && !callerCanceled {
continue
}
t.Run(fmt.Sprintf("%s/cancel=%t", stage, callerCanceled), func(t *testing.T) {
c, d := newSequenceTestClient(t, positionResponses(0x30, 30)...)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
stoppedAt := -1
stop := func() {
if stoppedAt >= 0 {
return
}
stoppedAt = len(d.commands)
if callerCanceled {
cancel()
} else {
now = now.Add(sequenceTimeout)
}
}
c.sequenceTiming.wait = func(_ context.Context, duration time.Duration) error {
now = now.Add(duration)
if (stage == "poll" && duration == sequencePollInterval) || (stage == "settle" && duration == sequenceResetWait) {
stop()
}
return nil
}
d.onCommand = func(cmd cmdType) {
if (stage == "initial AP" && cmd == cmdStatus) || (stage == "FC7" && cmd == cmdToEncoder) || (stage == "RS" && cmd == cmdReset) {
stop()
}
}
_, err := c.PrepareCurrentCard(ctx)
wantErr := error(ErrPreparationExhausted)
if callerCanceled {
wantErr = context.Canceled
} else if stage == "initial AP" {
wantErr = context.DeadlineExceeded
}
if !errors.Is(err, wantErr) || len(d.commands) != stoppedAt {
t.Errorf("stage=%s cancel=%t error=%v commands=%v stoppedAt=%d, want %v and no commands after stop", stage, callerCanceled, err, d.commands, stoppedAt, wantErr)
}
})
}
}
}
func TestPositionFlagsRemainDiagnostic(t *testing.T) {
for position := byte(0x30); position <= 0x3F; position++ {
st := status(position)
if got := isAtEncoderPosition(st); got != (position&0x02 != 0) {
t.Errorf("encoder sensor(%X)=%t", position, got)
}
if got := isCardWellEmpty(st); got != (position&0x08 != 0) {
t.Errorf("stock empty flag(%X)=%t", position, got)
}
wantStock := ""
if position&0x08 != 0 {
wantStock = "Card empty"
}
if got := stockTake(st); got != wantStock {
t.Errorf("stockTake(%X)=%q, want %q", position, got, wantStock)
}
}
}
func TestPreparationCallerDeadlineIsNotRetryableExhaustion(t *testing.T) {
c, d := newSequenceTestClient(t)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
_, err := c.PrepareCurrentCard(ctx)
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrPreparationExhausted) || len(d.commands) != 0 {
t.Errorf("expired caller: err=%v commands=%v, want caller deadline and no commands", err, d.commands)
}
}
func TestPreparationExpiredEncoderSampleHandsOff(t *testing.T) {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, cmdResp{status: status(0x37)})
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
d.onCommand = func(cmd cmdType) {
if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 {
now = now.Add(sequenceTimeout)
}
}
_, err := c.PrepareCurrentCard(context.Background())
if err != nil {
t.Errorf("late encoder sample: err=%v, want handoff", err)
}
if len(d.commands) != 3 {
t.Errorf("late encoder sample commands=%v, want AP FC7 AP", d.commands)
}
}

View File

@ -0,0 +1,242 @@
package dispenser
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
)
func TestStrictAPFailuresBecomeUnusablePreparationObservations(t *testing.T) {
transportAddress(t)
cases := []struct {
name string
chunks [][]byte
readErr error
}{
{"invalid ACK 10 06 30", [][]byte{{0x10, 0x06, 0x30}}, nil},
{"truncated", [][]byte{vendorACK, vendorAP[:8]}, nil},
{"no response", nil, nil},
{"timeout", nil, context.DeadlineExceeded},
{"read failure", nil, io.ErrClosedPipe},
}
for _, offset := range []int{0, 1, 2, 3, 4, 5, 6, 11, 12} {
frame := append([]byte(nil), vendorAP...)
frame[offset] ^= 0xff
if offset == 5 || offset == 6 || offset == 11 {
frame[12] = calculateBCC(frame[:12])
}
cases = append(cases, struct {
name string
chunks [][]byte
readErr error
}{fmt.Sprintf("frame byte %d", offset), [][]byte{vendorACK, frame}, nil})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: tc.chunks, readErr: tc.readErr}
st, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0)
if err == nil {
t.Fatal("strict queryStatus accepted invalid transaction")
}
c, d := newSequenceTestClient(t, cmdResp{status: st, err: err}, cmdResp{status: status(0x37)})
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("preparation after %s = %v, want opportunity", tc.name, err)
}
if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 {
t.Errorf("commands=%v, want one FC7 and no RS", d.commands)
}
})
}
}
func TestUnusableObservationsReclassifyAndShareUncertainty(t *testing.T) {
for _, later := range []byte{0x38, 0x30, 0x37, 0x35, 0x40} {
t.Run(fmt.Sprintf("later %X", later), func(t *testing.T) {
responses := []cmdResp{{err: io.ErrUnexpectedEOF}, {status: status(0x40)}}
responses = append(responses, positionResponses(later, 25)...)
c, d := newSequenceTestClient(t, responses...)
_, err := c.PrepareCurrentCard(context.Background())
var want error
if later == 0x38 {
want = ErrCardWellEmpty
}
if later == 0x30 {
want = ErrPreparationExhausted
}
if !errors.Is(err, want) {
t.Errorf("later %X error=%v, want %v", later, err, want)
}
resets := 0
if later == 0x30 {
resets = 3
}
if commandCount(d.commands, cmdReset) != resets {
t.Errorf("later %X commands=%v, want %d RS", later, d.commands, resets)
}
})
}
c, d := newSequenceTestClient(t, cmdResp{err: io.EOF}, cmdResp{err: io.EOF}, cmdResp{status: status(0x35)}, cmdResp{status: status(0x40)}, cmdResp{err: io.EOF}, cmdResp{status: status(0x39)})
start := c.now()
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if elapsed := c.now().Sub(start); elapsed != sequenceUncertainWait {
t.Errorf("mixed uncertainty elapsed=%v, want %v", elapsed, sequenceUncertainWait)
}
if commandCount(d.commands, cmdToEncoder) != 1 {
t.Errorf("mixed uncertainty commands=%v, want one FC7", d.commands)
}
}
func TestObservationAtPreparationDeadline(t *testing.T) {
for _, last := range []cmdResp{{err: io.EOF}, {status: status(0x40)}, {status: status(0x35)}, {status: status(0x30)}, {status: status(0x38)}} {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, last)
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
d.onCommand = func(cmd cmdType) {
if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 {
now = now.Add(sequenceTimeout)
}
}
_, err := c.PrepareCurrentCard(context.Background())
var want error
if len(last.status) == 4 && last.status[3] == 0x30 {
want = ErrPreparationExhausted
}
if len(last.status) == 4 && last.status[3] == 0x38 {
want = ErrCardWellEmpty
}
if !errors.Is(err, want) {
t.Errorf("deadline observation=%v error=%v, want %v", last, err, want)
}
}
}
func TestWorkerClearanceFallbackAndMovement(t *testing.T) {
for _, mode := range []string{"unusable", "ambiguous", "movement", "movement then unusable", "empty", "cancel"} {
t.Run(mode, func(t *testing.T) {
p := &scriptedTransport{}
for i := 0; i < 6; i++ {
switch mode {
case "unusable", "cancel":
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
case "ambiguous":
p.chunks = append(p.chunks, vendorACK, apReply(status(0x37)))
case "empty":
p.chunks = append(p.chunks, vendorACK, apReply(status(0x38)))
default:
if mode == "movement then unusable" && i > 0 {
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
} else {
p.chunks = append(p.chunks, vendorACK, apReply([]byte{0x31, 0x30, 0x30, 0x34}))
}
}
}
c, now := deliveryWorkerClient(t, p)
c.sequenceTiming.wait = func(ctx context.Context, d time.Duration) error {
if ctx.Err() != nil {
return ctx.Err()
}
*now = now.Add(2 * d)
return nil
}
c.deliveryPending = true
c.deliveryStarted = c.now()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if mode == "cancel" {
p.afterRead = cancel
}
r := c.doResponse(ctx, cmdDeliveryClearance)
if mode == "cancel" {
if !errors.Is(r.err, context.Canceled) {
t.Errorf("cancel=%v", r.err)
}
return
}
var want error
if mode == "movement" {
want = context.DeadlineExceeded
}
if mode == "empty" {
want = ErrCardWellEmpty
}
if !errors.Is(r.err, want) {
t.Errorf("clearance %s error=%v want %v", mode, r.err, want)
}
if c.deliveryPending != (want != nil) {
t.Errorf("clearance %s pending=%t", mode, c.deliveryPending)
}
if mode != "empty" && now.Sub(time.Unix(0, 0)) != deliveryClearanceTimeout {
t.Errorf("clearance %s wait=%v want 6s", mode, now.Sub(time.Unix(0, 0)))
}
for _, command := range wireCommands(p) {
if command != "AP" {
t.Errorf("clearance issued %s, want AP only", command)
}
}
})
}
}
func TestWorkerAssumedClearanceAllowsOneFC7(t *testing.T) {
for _, prepare := range []bool{false, true} {
p := &scriptedTransport{}
dispatched := false
p.afterWrite = func() {
w := p.writes[len(p.writes)-1]
if len(w) <= 6 || w[0] != STX {
return
}
switch string(w[5 : len(w)-2]) {
case "AP":
if dispatched {
p.chunks = append(p.chunks, vendorACK, apReply(status(0x35)))
} else {
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
}
case "FC7":
dispatched = true
p.chunks = append(p.chunks, vendorACK)
}
}
c, now := deliveryWorkerClient(t, p)
c.deliveryPending = true
c.deliveryStarted = c.now()
var err error
if prepare {
_, err = c.PrepareCurrentCard(context.Background())
} else {
err = c.BeginPrepareNextCard(context.Background())
}
if err != nil {
t.Fatalf("prepare=%t error=%v", prepare, err)
}
wantElapsed := deliveryClearanceTimeout
if prepare {
wantElapsed += sequenceUncertainWait
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != wantElapsed {
t.Errorf("prepare=%t elapsed=%v want %v", prepare, elapsed, wantElapsed)
}
commands := wireCommands(p)
fc7 := 0
for _, cmd := range commands {
if cmd == "FC7" {
fc7++
}
if cmd == "RS" {
t.Errorf("unexpected RS: %v", commands)
}
}
if fc7 != 1 || c.deliveryPending {
t.Errorf("prepare=%t commands=%v pending=%t, want one FC7 and cleared", prepare, commands, c.deliveryPending)
}
if !prepare && commands[len(commands)-1] != "FC7" {
t.Errorf("prestaging polled after FC7: %v", commands)
}
}
}

View File

@ -0,0 +1,272 @@
package dispenser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"testing"
"time"
)
// Golden frames reconstructed from K720_Dll.dll's big-endian length and XOR
// algorithm (SendCmd 0x100050C0, Query 0x10005280, SensorQuery 0x10005420).
var vendorFrames = []struct {
name string
command []byte
frame []byte
}{
{"AP", []byte{2, 'A', 'P'}, []byte{2, 0x30, 0x30, 0, 2, 0x41, 0x50, 3, 0x12}},
{"RF", []byte{2, 'R', 'F'}, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x46, 3, 0x17}},
{"FC7", commandFC7, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x37, 3, 0x30}},
{"FC0", commandFC0, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x30, 3, 0x37}},
{"RS", commandRS, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x53, 3, 2}},
}
func TestVendorOutboundFrames(t *testing.T) {
for _, tc := range vendorFrames {
t.Run(tc.name, func(t *testing.T) {
if got := createPacket([]byte("00"), tc.command); !bytes.Equal(got, tc.frame) {
t.Errorf("createPacket(%s) = % X, want % X", tc.name, got, tc.frame)
}
if got := calculateBCC(tc.frame[:len(tc.frame)-1]); got != tc.frame[len(tc.frame)-1] {
t.Errorf("calculateBCC(%s) = %02X, want %02X", tc.name, got, tc.frame[len(tc.frame)-1])
}
})
}
}
type scriptedTransport struct {
chunks [][]byte
writes [][]byte
readErr error
writeErr error
shortWrite int
afterRead func()
afterWrite func()
}
func (p *scriptedTransport) Read(b []byte) (int, error) {
if len(p.chunks) == 0 {
if p.readErr != nil {
return 0, p.readErr
}
return 0, io.EOF
}
n := copy(b, p.chunks[0])
p.chunks[0] = p.chunks[0][n:]
if len(p.chunks[0]) == 0 {
p.chunks = p.chunks[1:]
}
if p.afterRead != nil {
p.afterRead()
}
return n, nil
}
func (p *scriptedTransport) Write(b []byte) (int, error) {
p.writes = append(p.writes, append([]byte(nil), b...))
if p.afterWrite != nil {
p.afterWrite()
}
if p.writeErr != nil {
return 0, p.writeErr
}
if p.shortWrite == len(p.writes) {
return len(b) - 1, nil
}
return len(b), nil
}
func transportAddress(t *testing.T) {
t.Helper()
old := Address
Address = []byte("00")
t.Cleanup(func() { Address = old })
}
// Independent SF response vectors: status is 30 30 30 [33].
var vendorAP = []byte{2, 0x30, 0x30, 0, 6, 'S', 'F', 0x30, 0x30, 0x30, 0x33, 3, 0x11}
var vendorRF = []byte{2, 0x30, 0x30, 0, 5, 'S', 'F', 0x30, 0x30, 0x30, 3, 0x21}
var vendorACK = []byte{6, 0x30, 0x30}
func TestVendorQueryFragmentation(t *testing.T) {
transportAddress(t)
for i, frame := range [][]byte{vendorAP, vendorRF} {
wire := append(append([]byte(nil), vendorACK...), frame...)
for split := 1; split < len(wire); split++ {
t.Run(fmt.Sprintf("%s/split%d", vendorFrames[i].name, split), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{wire[:split], wire[split:]}}
got, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0)
if err != nil || !bytes.Equal(got, frame[7:len(frame)-2]) {
t.Fatalf("queryStatus(split=%d) = % X, %v, want % X, nil", split, got, err, frame[7:len(frame)-2])
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], vendorFrames[i].frame) || !bytes.Equal(p.writes[1], []byte{5, 0x30, 0x30}) {
t.Errorf("queryStatus writes = % X, want command then ENQ", p.writes)
}
})
}
t.Run(vendorFrames[i].name+"/one-byte", func(t *testing.T) {
p := &scriptedTransport{}
for _, b := range wire {
p.chunks = append(p.chunks, []byte{b})
}
if _, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0); err != nil {
t.Errorf("queryStatus(one-byte reads) = %v, want nil", err)
}
})
}
}
func TestVendorQueryRejectsInvalidFrames(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
offset int
value byte
}{
{"STX", 0, 1}, {"address high", 1, '1'}, {"address low", 2, '1'},
{"zero length", 4, 0}, {"short length", 4, 5}, {"long length", 4, 7}, {"high length", 3, 0xff},
{"type S", 5, 'X'}, {"type F", 6, 'X'}, {"ETX", 11, 4}, {"BCC", 12, 0},
} {
t.Run(tc.name, func(t *testing.T) {
frame := append([]byte(nil), vendorAP...)
frame[tc.offset] = tc.value
// Keep checksum valid when testing type/ETX to isolate those checks.
if tc.offset == 5 || tc.offset == 6 || tc.offset == 11 {
frame[12] = 0
for _, b := range frame[:12] {
frame[12] ^= b
}
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if got, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil || got != nil {
t.Errorf("queryStatus(%s) = % X, %v, want nil/error", tc.name, got, err)
}
if len(p.writes) != 2 {
t.Errorf("queryStatus(%s) writes=%d, want 2 without resend", tc.name, len(p.writes))
}
})
}
for n := 0; n < len(vendorAP); n++ {
t.Run(fmt.Sprintf("truncated%d", n), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP[:n]}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil {
t.Errorf("queryStatus(%d-byte frame) succeeded, want error", n)
}
})
}
}
func TestTransportACKAndMechanicalDispatch(t *testing.T) {
transportAddress(t)
for _, tc := range vendorFrames[2:] {
p := &scriptedTransport{chunks: [][]byte{{6}, {'0'}, {'0'}}}
if err := dispatchCommand(context.Background(), p, tc.command, 0); err != nil {
t.Fatalf("dispatchCommand(%s)=%v, want nil", tc.name, err)
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], tc.frame) || !bytes.Equal(p.writes[1], []byte{5, '0', '0'}) {
t.Errorf("dispatchCommand(%s) writes=% X, want command then ENQ", tc.name, p.writes)
}
}
for _, ack := range [][]byte{{0x15, '0', '0'}, {6, '1', '0'}, {6, '0', '1'}, {6}, {6, '0'}, {}} {
p := &scriptedTransport{chunks: [][]byte{ack}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Errorf("dispatchCommand(ACK=% X) succeeded, want error", ack)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(ACK=% X) writes=%d, want 1", ack, len(p.writes))
}
}
}
func TestTransportShortWritesAndErrors(t *testing.T) {
transportAddress(t)
failure := errors.New("serial failure")
for _, stage := range []int{1, 2} {
p := &scriptedTransport{chunks: [][]byte{vendorACK}, shortWrite: stage}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); !errors.Is(err, io.ErrShortWrite) {
t.Errorf("dispatchCommand(short write %d)=%v, want ErrShortWrite", stage, err)
}
if len(p.writes) != stage {
t.Errorf("short write %d writes=%d, want %d", stage, len(p.writes), stage)
}
}
for _, p := range []*scriptedTransport{{writeErr: failure}, {readErr: failure}, {chunks: [][]byte{{}}}} {
if err := dispatchCommand(context.Background(), p, commandRS, 0); err == nil {
t.Error("dispatchCommand(I/O failure) succeeded, want error")
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(I/O failure) writes=%d, want 1", len(p.writes))
}
}
}
func TestTransportCancellation(t *testing.T) {
transportAddress(t)
for _, stage := range []string{"before write", "processing wait", "after ACK", "after ENQ", "after header"} {
t.Run(stage, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP}}
wantWrites := 1
switch stage {
case "before write":
cancel()
wantWrites = 0
case "processing wait":
p.afterWrite = cancel
case "after ACK":
p.afterRead = cancel
case "after ENQ":
wantWrites = 2
p.afterWrite = func() {
if len(p.writes) == 2 {
cancel()
}
}
case "after header":
wantWrites = 2
reads := 0
p.afterRead = func() {
reads++
if reads == 2 {
cancel()
}
}
}
if _, err := queryStatus(ctx, p, vendorFrames[0].command, 4, 0); !errors.Is(err, context.Canceled) {
t.Errorf("queryStatus(cancel %s)=%v, want Canceled", stage, err)
}
if len(p.writes) != wantWrites {
t.Errorf("queryStatus(cancel %s) writes=%d, want %d", stage, len(p.writes), wantWrites)
}
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
p := &scriptedTransport{}
if err := dispatchCommand(ctx, p, commandRS, time.Second); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("dispatchCommand(deadline in wait)=%v, want DeadlineExceeded", err)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(deadline) writes=%d, want 1", len(p.writes))
}
}
func TestTransportTrailingDataNotConsumedAsStatus(t *testing.T) {
transportAddress(t)
frame := append(append([]byte(nil), vendorAP...), 0xff, 0xfe, 0xfd)
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err != nil {
t.Fatalf("queryStatus(frame with trailing bytes)=%v, want nil", err)
}
if len(p.chunks) != 1 || !bytes.Equal(p.chunks[0], []byte{0xff, 0xfe, 0xfd}) {
t.Fatalf("remaining bytes=% X, want FF FE FD", p.chunks)
}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Error("dispatchCommand(trailing garbage) succeeded, want invalid ACK")
}
if len(p.writes) != 3 {
t.Errorf("writes after trailing garbage=%d, want 3 (no ENQ/resend)", len(p.writes))
}
}

View File

@ -121,11 +121,11 @@ func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
wantCardWell string
}{
{
name: "initial dispenser preparation failure is unavailable",
name: "initial dispenser preparation failure is retryable",
dispenser: fakeDoorCardDispenser{
prepareCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("card jammed")},
},
wantHTTP: http.StatusServiceUnavailable,
wantHTTP: http.StatusBadGateway,
wantMessage: "Dispense error: card jammed",
wantCalls: []string{"prepare current"},
wantCardWell: "Card jammed",
@ -140,6 +140,13 @@ func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
wantCalls: []string{"prepare current"},
wantCardWell: "Card empty",
},
{
name: "exhausted preparation is retryable without encoding or delivery",
dispenser: fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.Join(errors.New("preparation"), dispenser.ErrPreparationExhausted)}},
wantHTTP: http.StatusBadGateway,
wantMessage: "preparation\ncard preparation exhausted",
wantCalls: []string{"prepare current"},
},
{
name: "encoding and accepted delivery command succeed",
wantHTTP: http.StatusOK,
@ -148,13 +155,13 @@ func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
wantLockSequence: 1,
},
{
name: "successful encoding with delivery command failure is unavailable",
name: "successful encoding with delivery command failure still prestages and succeeds",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
wantHTTP: http.StatusServiceUnavailable,
wantMessage: "Card delivery could not be confirmed",
wantCalls: []string{"prepare current", "deliver current"},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
@ -169,36 +176,45 @@ func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
wantLockSequence: 1,
},
{
name: "encoding failure with safe recovery remains retryable",
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
name: "delivery clearance deferral does not undo successful issuance",
dispenser: fakeDoorCardDispenser{
beginNextErr: errors.New("next-card preparation deferred: previous delivery is not clear"),
},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "encoding failure with failed-card delivery failure is unavailable",
name: "encoding failure ends after delivery",
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
},
{
name: "encoding failure remains retryable despite FC0 failure",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
lockErr: encodingErr,
wantHTTP: http.StatusServiceUnavailable,
wantMessage: "Dispenser recovery failed; another encoding attempt is not safe",
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
{
name: "encoding failure with empty card well has stable message",
name: "encoding failure never prepares next card",
dispenser: fakeDoorCardDispenser{
prepareNext: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
},
lockErr: encodingErr,
wantHTTP: http.StatusServiceUnavailable,
wantMessage: dispenser.CardWellEmptyMessage,
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
wantCardWell: "Card empty",
},
}
@ -311,3 +327,53 @@ func TestIssueDoorCardLogsNextCardDispatchFailureAndStillSucceeds(t *testing.T)
t.Fatalf("dispatch failure log = %q", logged)
}
}
func TestIssueDoorCardLogsDeliveryFailureWithoutReplacingEncoderOutcome(t *testing.T) {
for _, encodingErr := range []error{nil, errors.New("original encoder failure")} {
var output bytes.Buffer
logger := log.StandardLogger()
previous := logger.Out
logger.SetOutput(&output)
d := &fakeDoorCardDispenser{deliverCurrent: dispenserCallResult{err: errors.New("FC0 dispatch failed")}}
lock := &fakeDoorCardLockServer{sequenceErr: encodingErr}
recorder, response, _ := performIssueDoorCardRequest(t, d, lock)
logger.SetOutput(previous)
wantHTTP := http.StatusOK
if encodingErr != nil {
wantHTTP = http.StatusBadGateway
}
if recorder.Code != wantHTTP {
t.Errorf("issueDoorCard(%v) HTTP = %d, want %d", encodingErr, recorder.Code, wantHTTP)
}
if encodingErr != nil && response.Message != encodingErr.Error() {
t.Errorf("issueDoorCard message = %q, want %q", response.Message, encodingErr.Error())
}
if !strings.Contains(output.String(), "FC0 dispatch failed") || !strings.Contains(output.String(), "Card delivery") {
t.Errorf("issueDoorCard delivery log = %q, want FC0 failure and Card delivery", output.String())
}
}
}
func TestIssueDoorCardOnlyEmptyPreparationReturns503(t *testing.T) {
for _, failure := range []error{
dispenser.ErrCardWellEmpty, errors.Join(errors.New("wrapped"), dispenser.ErrCardWellEmpty),
dispenser.ErrPreparationExhausted, context.Canceled, context.DeadlineExceeded,
errors.New("malformed AP"), errors.New("truncated AP"), errors.New("no response"),
errors.New("serial read failed"), errors.New("serial write failed"), errors.New("FC7 dispatch failed"),
errors.New("RS dispatch failed"), errors.New(dispenser.CardWellEmptyMessage), errors.New("other preparation error"),
} {
d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: failure}}
lock := &fakeDoorCardLockServer{}
recorder, response, _ := performIssueDoorCardRequest(t, d, lock)
want := http.StatusBadGateway
if errors.Is(failure, dispenser.ErrCardWellEmpty) {
want = http.StatusServiceUnavailable
}
if recorder.Code != want || response.Code != want {
t.Errorf("preparation %v HTTP=%d body=%d, want %d", failure, recorder.Code, response.Code, want)
}
if lock.sequenceCalls != 0 || !reflect.DeepEqual(d.calls, []string{"prepare current"}) {
t.Errorf("preparation %v encoder=%d calls=%v, want no physical continuation", failure, lock.sequenceCalls, d.calls)
}
}
}

View File

@ -6,7 +6,6 @@ import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strings"
@ -299,7 +298,11 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
return
}
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
if errors.Is(err, dispenser.ErrPreparationExhausted) {
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
return
}
errorhandlers.WriteError(w, http.StatusBadGateway, "Dispense error: "+err.Error())
return
}
@ -307,8 +310,11 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence
// Each request performs at most one encoder operation.
encodingStarted := time.Now()
log.Info("LockSequence started")
encodingErr := app.lockserver.LockSequence()
log.Infof("LockSequence finished; success=%t duration=%s", encodingErr == nil, time.Since(encodingStarted))
if encodingErr != nil {
logging.Error(types.ServiceName, encodingErr.Error(), "Key encoding", string(op), "", "", 0)
}
@ -321,32 +327,10 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
status, deliveryErr := app.disp.DeliverCurrentCard(finalizeCtx)
app.SetCardWellStatus(status)
if deliveryErr != nil {
if encodingErr != nil {
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, deliveryErr)
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
return
}
logging.Error(types.ServiceName, deliveryErr.Error(), "Card delivery", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Card delivery could not be confirmed")
return
}
if encodingErr != nil {
status, preparationErr := app.disp.PrepareNextCard(finalizeCtx)
app.SetCardWellStatus(status)
if preparationErr != nil {
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, preparationErr)
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
if errors.Is(preparationErr, dispenser.ErrCardWellEmpty) {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
return
}
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
return
}
// FC0 is attempted once; the next UI request owns all further preparation.
errorhandlers.WriteError(w, http.StatusBadGateway, encodingErr.Error())
return
}

View File

@ -2,6 +2,12 @@
builtVersion is a const in main.go
#### v2.1.0 - 28 September 2026
fix(dispenser): tolerate unusable AP observations during card preparation
#### v2.0.3 - 24 September 2026
fix(dispenser): recover stuck card preparation with reset
#### v2.0.2 - 22 September 2026
fix(dispenser): retry on transient prepare failure