diff --git a/.gitignore b/.gitignore index aaf540b..609bab7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ _obj _test .vscode/ ChipDNAClient/ +docs/ # Architecture specific extensions/prefixes *.[568vq] diff --git a/internal/dispenser/dispenser.go b/internal/dispenser/dispenser.go index c2bcd1e..8d29c44 100644 --- a/internal/dispenser/dispenser.go +++ b/internal/dispenser/dispenser.go @@ -1,8 +1,11 @@ package dispenser import ( + "context" + "encoding/binary" "errors" "fmt" + "io" "strings" "time" @@ -27,6 +30,13 @@ const ( CardWellEmptyMessage = "Card well is empty" ) +const ( + positionPreDispense = 0x01 + positionEncoder = 0x02 + positionMouth = 0x04 + positionEmpty = 0x08 +) + var ( ErrCardWellEmpty = errors.New(CardWellEmptyMessage) @@ -59,20 +69,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("", len(statusBytes)) @@ -86,7 +97,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 @@ -99,6 +109,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() } @@ -107,7 +135,11 @@ 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 { @@ -115,12 +147,15 @@ func validateDispenserStatusData(statusBytes []byte) error { return fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(statusBytes)) } - statusMaps := []map[byte]string{statusPos0, statusPos1, statusPos2, statusPos3} + statusMaps := []map[byte]string{statusPos0, statusPos1, statusPos2} 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) } } + if _, valid := decodePositionStatus(statusBytes[3]); !valid { + return fmt.Errorf("unknown dispenser status 0x%X at position 4", statusBytes[3]) + } return nil } @@ -162,14 +197,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 { @@ -208,20 +247,99 @@ 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 { + if err := ctx.Err(); err != nil { + return err } - return buf[:n], nil + n, err := port.Write(packet) + if err != nil { + return fmt.Errorf("write dispenser packet: %w", err) + } + if n != len(packet) { + return fmt.Errorf("write dispenser packet (%d/%d bytes): %w", n, len(packet), io.ErrShortWrite) + } + return 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 } // -------------------- @@ -271,83 +389,28 @@ 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 - } - - statusResp, err = sendAndReceive(port, enq, delay) - if err != nil { - return nil, fmt.Errorf("error sending ENQ: %w", err) - } - if len(statusResp) < 13 { - return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp) - } - return statusResp[7:11], nil +func checkDispenserStatus(ctx context.Context, port serialTransport) ([]byte, error) { + return queryStatus(ctx, port, []byte{0x02, 'A', 'P'}, 4, delay) } -func cardToEncoderPosition(port *serial.Port) error { - enq := append([]byte{ENQ}, Address...) +// 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 + } + return writePacket(ctx, port, 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 - } - - _, err = port.Write(enq) - if err != nil { - return fmt.Errorf("error sending ENQ to prompt device: %w", err) - } - return nil + return dispatchCommand(ctx, port, commandFC7, delay) } -func resetDispenser(port *serial.Port) error { - response, err := sendAndReceive(port, createPacket(Address, commandRS), delay) - if err != nil { - return fmt.Errorf("error sending reset command: %w", err) - } - if err := checkACK(response); err != nil { - return err - } - if _, err := port.Write(append([]byte{ENQ}, Address...)); err != nil { - return fmt.Errorf("error sending ENQ to reset 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) 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 := 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 dispatchCommand(ctx, port, commandFC0, delay) } diff --git a/internal/dispenser/dispenserclient.go b/internal/dispenser/dispenserclient.go index c9c6e62..fa8ef26 100644 --- a/internal/dispenser/dispenserclient.go +++ b/internal/dispenser/dispenserclient.go @@ -44,7 +44,7 @@ const ( ) type Client struct { - port *serial.Port + port serialTransport reqCh chan cmdReq done chan struct{} @@ -189,7 +189,7 @@ func (c *Client) handle(req cmdReq) { switch req.typ { case cmdStatus: - st, err := checkDispenserStatus(c.port) + st, err := checkDispenserStatus(req.ctx, c.port) if err == nil && len(st) == 4 { c.mu.Lock() c.lastStatus = append([]byte(nil), st...) @@ -202,18 +202,18 @@ func (c *Client) handle(req cmdReq) { req.respCh <- cmdResp{status: st, err: err} case cmdToEncoder: - err := cardToEncoderPosition(c.port) + err := cardToEncoderPosition(req.ctx, c.port) // A movement command makes any previously cached position unreliable. c.invalidateStatusCache() req.respCh <- cmdResp{err: err} case cmdReset: - err := resetDispenser(c.port) + err := resetDispenser(req.ctx, c.port) c.invalidateStatusCache() req.respCh <- cmdResp{err: err} case cmdOutOfMouth: - err := cardOutOfMouth(c.port) + err := cardOutOfMouth(req.ctx, c.port) // A movement command makes any previously cached position unreliable. c.invalidateStatusCache() req.respCh <- cmdResp{err: err} @@ -340,9 +340,7 @@ func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]by } func preparationStatus(operation string, status []byte, allowCombinedFailure bool) (bool, error) { - if len(status) != 4 { - return false, fmt.Errorf("[%s] %w", operation, validateDispenserStatusData(status)) - } + // A confirmed read sensor takes precedence over every other diagnostic. if isAtEncoderPosition(status) { if hasPreparationDiagnostics(status) { log.Warnf( @@ -367,7 +365,7 @@ func preparationStatus(operation string, status []byte, allowCombinedFailure boo // 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 + // pollForEncoderPosition decide encoder success or timeout. Do not mask // independent hard errors reported in the other status bytes. // Combined command rejection is also recoverable before the one retry. if status[0] == 0x32 || (status[0] == 0x36 && allowCombinedFailure) { diff --git a/internal/dispenser/dispenserclient_test.go b/internal/dispenser/dispenserclient_test.go index 46ba412..6958a4b 100644 --- a/internal/dispenser/dispenserclient_test.go +++ b/internal/dispenser/dispenserclient_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "reflect" "strings" "testing" @@ -172,7 +173,7 @@ func TestPreparationHardErrorsNeverReset(t *testing.T) { {0x36, 0x32, 0x30, 0x30}, // Independent dispense error. {0x32, 0x31, 0x30, 0x30}, // Independent capture error. {0x34, 0x30, 0x30, 0x30}, // Command rejection alone. - {0x30, 0x30, 0x30, 0x39}, // Unknown. + {0x30, 0x30, 0x30, 0x40}, // Unknown. {0x32}, // Malformed. } { responses := prepareFailureResponses(0x32, 7) @@ -482,7 +483,7 @@ func TestPrepareCurrentCardRejectsFailureWithoutEncoderOrMovement(t *testing.T) }, { name: "unknown status", - response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x39}}, + response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x40}}, wantError: "unknown dispenser status", }, { @@ -696,3 +697,131 @@ func TestPrepareCardAtEncoderRequiresConfirmedEncoderState(t *testing.T) { }) } } + +func TestPositionFlags(t *testing.T) { + for position := byte(0x30); position <= 0x3F; position++ { + t.Run(fmt.Sprintf("%02X", position), func(t *testing.T) { + st := status(position) + wantEncoder := position&0x02 != 0 + wantEmpty := position&0x08 != 0 + if got := isAtEncoderPosition(st); got != wantEncoder { + t.Errorf("isAtEncoderPosition(% X) = %t, want %t", st, got, wantEncoder) + } + if got := isCardWellEmpty(st); got != wantEmpty { + t.Errorf("isCardWellEmpty(% X) = %t, want %t", st, got, wantEmpty) + } + if err := validateDispenserStatusData(st); err != nil { + t.Errorf("validateDispenserStatusData(% X) = %v, want nil", st, err) + } + ready, err := preparationStatus("test", st, true) + if ready != wantEncoder { + t.Errorf("preparationStatus(% X) ready = %t, want %t", st, ready, wantEncoder) + } + wantEmptyError := wantEmpty && !wantEncoder + if errors.Is(err, ErrCardWellEmpty) != wantEmptyError || (err != nil && !wantEmptyError) { + t.Errorf("preparationStatus(% X) error = %v, want empty error %t", st, err, wantEmptyError) + } + wantStock := "" + if wantEmpty { + wantStock = "Card empty" + } + if got := stockTake(st); got != wantStock { + t.Errorf("stockTake(% X) = %q, want %q", st, got, wantStock) + } + }) + } +} + +func TestEncoderPositionPrecedesDiagnostics(t *testing.T) { + for _, position := range []byte{0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F} { + for _, diagnostics := range [][]byte{ + {0x30, 0x30, 0x30}, + {0x32, 0x30, 0x30}, + {0x36, 0x30, 0x30}, + {0x34, 0x32, 0x32}, + {0x30, 0x31, 0x34}, + {0xFF, 0xFF, 0xFF}, + } { + st := append(append([]byte(nil), diagnostics...), position) + for _, operation := range []string{"current", "next"} { + t.Run(fmt.Sprintf("%s/% X", operation, st), func(t *testing.T) { + client, device := newSequenceTestClient(t, cmdResp{status: st}) + prepare := client.PrepareCurrentCard + if operation == "next" { + prepare = client.PrepareNextCard + } + if _, err := prepare(context.Background()); err != nil { + t.Errorf("prepare(% X) = %v, want success", st, err) + } + want := []cmdType{cmdStatus} + if !reflect.DeepEqual(device.commands, want) { + t.Errorf("prepare(% X) commands = %v, want %v", st, device.commands, want) + } + }) + } + } + } +} + +func TestCombinedEncoderPositionStopsRecovery(t *testing.T) { + for _, afterReset := range []bool{false, true} { + t.Run(fmt.Sprintf("afterReset=%t", afterReset), func(t *testing.T) { + count := 7 // Encoder confirmation at the recovery threshold, before RS. + wantResets := 0 + if afterReset { + count = 8 // Encoder confirmation on the fresh post-reset read. + wantResets = 1 + } + responses := append(prepareFailureResponses(0x32, count), cmdResp{status: []byte{0x36, 0x32, 0x34, 0x3F}}) + client, device := newSequenceTestClient(t, responses...) + if _, err := client.PrepareCurrentCard(context.Background()); err != nil { + t.Errorf("PrepareCurrentCard(combined encoder) = %v, want success", err) + } + if got := commandCount(device.commands, cmdReset); got != wantResets { + t.Errorf("PrepareCurrentCard(combined encoder) RS count = %d, want %d", got, wantResets) + } + if got := commandCount(device.commands, cmdToEncoder); got != 1 { + t.Errorf("PrepareCurrentCard(combined encoder) FC7 count = %d, want 1", got) + } + }) + } +} + +func TestInvalidPositionEncoding(t *testing.T) { + for _, st := range [][]byte{nil, {0x30, 0x30, 0x30}, status(0x02), status(0x2F), status(0x40), status(0x72), status(0xFF)} { + if isAtEncoderPosition(st) || isCardWellEmpty(st) { + t.Errorf("invalid status % X confirmed encoder or empty, want neither", st) + } + if ready, err := preparationStatus("test", st, true); ready || err == nil { + t.Errorf("preparationStatus(% X) = (%t, %v), want false and error", st, ready, err) + } + } + st := []byte{0xFF, 0xFF, 0xFF, 0x37, 0xFF} + if ready, err := preparationStatus("test", st, false); !ready || err != nil { + t.Errorf("preparationStatus(% X) = (%t, %v), want encoder precedence", st, ready, err) + } + st = append(status(0x35), 0xFF) + if ready, err := preparationStatus("test", st, true); ready || err == nil { + t.Errorf("preparationStatus(% X) = (%t, %v), want malformed error", st, ready, err) + } +} + +func TestPositionDescriptions(t *testing.T) { + for _, test := range []struct { + position byte + want string + }{ + {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) + } + } +} diff --git a/internal/dispenser/transport_test.go b/internal/dispenser/transport_test.go new file mode 100644 index 0000000..69ee972 --- /dev/null +++ b/internal/dispenser/transport_test.go @@ -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)) + } +}