package dispenser import ( "context" "encoding/binary" "errors" "fmt" "io" "strings" "time" log "github.com/sirupsen/logrus" "github.com/tarm/serial" ) // Control characters. const ( STX = 0x02 // Start of Text ETX = 0x03 // End of Text ACK = 0x06 // Positive response NAK = 0x15 // Negative response ENQ = 0x05 // Enquiry from host space = 0x00 // Space character baudRate = 9600 // Baud rate for serial communication delay = 500 * time.Millisecond // Delay for processing commands // cache freshness for "continuous status" reads (tune as you wish) defaultStatusTTL = 1500 * time.Millisecond 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", 0x34: "Command cannot execute", 0x32: "Preparing card fails", 0x31: "Preparing card", 0x30: "Normal", 0x36: "Command cannot execute; Preparing card fails", } statusPos1 = map[byte]string{ 0x38: "Dispensing card", 0x34: "Capturing card", 0x32: "Dispense card error", 0x31: "Capture card error", 0x30: "Normal", } statusPos2 = map[byte]string{ 0x38: "No captured card", 0x34: "Card overlapped", 0x32: "Card jammed", 0x31: "Card pre-empty", 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)) } posStatus := []struct { pos int value byte mapper map[byte]string }{ {pos: 1, value: statusBytes[0], mapper: statusPos0}, {pos: 2, value: statusBytes[1], mapper: statusPos1}, {pos: 3, value: statusBytes[2], mapper: statusPos2}, } var result strings.Builder for _, p := range posStatus { statusMsg, exists := p.mapper[p.value] if !exists { statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos) } if p.value != 0x30 { 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() } func logStatus(statusBytes []byte) { log.Infof("Dispenser status: %s", statusDescription(statusBytes)) } func isAtEncoderPosition(statusBytes []byte) bool { if len(statusBytes) < 4 { return false } flags, valid := decodePositionStatus(statusBytes[3]) return valid && flags&positionEncoder != 0 } type preparationClass string const ( encoderConfirmed preparationClass = "encoder confirmed" positionUncertain preparationClass = "valid but uncertain" positionWellEmpty preparationClass = "empty" positionNoCard preparationClass = "no card on sensors" ) // 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)) } flags, valid := decodePositionStatus(status[3]) if !valid { return "", fmt.Errorf("malformed dispenser position encoding: 0x%X", status[3]) } 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 } } func isPreparationMoving(statusBytes []byte) bool { return len(statusBytes) == 4 && (statusBytes[0] == 0x31 || statusBytes[1] == 0x38) } func stockTake(statusBytes []byte) string { if len(statusBytes) < 4 { return "" } status := "" if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 { status = statusPos0[statusBytes[0]] } if statusBytes[2] != 0x30 { status = statusPos2[statusBytes[2]] } if isCardWellEmpty(statusBytes) { status = "Card empty" } return status } func isCardWellEmpty(statusBytes []byte) bool { if len(statusBytes) < 4 { return false } flags, valid := decodePositionStatus(statusBytes[3]) return valid && flags&positionEmpty != 0 } func checkACK(statusResp []byte) error { if len(statusResp) == 3 && statusResp[0] == ACK && len(Address) >= 2 && statusResp[1] == Address[0] && statusResp[2] == Address[1] { return nil } if len(statusResp) > 0 && statusResp[0] == NAK { return fmt.Errorf("negative response from dispenser") } return fmt.Errorf("unexpected response status: % X", statusResp) } // calculateBCC computes BCC as XOR of all bytes from STX to ETX. func calculateBCC(data []byte) byte { var bcc byte for _, b := range data { bcc ^= b } return bcc } func createPacket(address []byte, command []byte) []byte { packet := []byte{STX} packet = append(packet, address...) packet = append(packet, space) packet = append(packet, command...) packet = append(packet, ETX) bcc := calculateBCC(packet) packet = append(packet, bcc) return packet } func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) } // serialTransport is used only by the serial-port owner. type serialTransport interface { io.Reader io.Writer } func writePacket(ctx context.Context, port serialTransport, packet []byte) error { _, err := writePacketAttempt(ctx, port, packet) return err } // 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 } // -------------------- // Serial init (3 attempts) // -------------------- func InitializeDispenser() (*serial.Port, error) { const ( funcName = "InitializeDispenser" maxRetries = 3 retryDelay = 4 * time.Second ) if SerialPort == "" { return nil, fmt.Errorf("%s: SerialPort is empty", funcName) } if len(Address) < 2 { return nil, fmt.Errorf("%s: Address must be at least 2 bytes", funcName) } serialConfig := &serial.Config{ Name: SerialPort, Baud: baudRate, ReadTimeout: 2 * time.Second, } var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { port, err := serial.OpenPort(serialConfig) if err == nil { log.Infof("%s: dispenser opened on %s (attempt %d/%d)", funcName, SerialPort, attempt, maxRetries) return port, nil } lastErr = err log.Warnf("%s: failed to open dispenser on %s (attempt %d/%d): %v", funcName, SerialPort, attempt, maxRetries, err) if attempt < maxRetries { time.Sleep(retryDelay) } } return nil, fmt.Errorf("%s: failed to open dispenser on %s after %d attempts: %w", funcName, SerialPort, maxRetries, lastErr) } // -------------------- // Internal (port-owner only) operations // -------------------- // checkDispenserStatus talks to the device and returns the 4 status bytes [pos0..pos3]. func checkDispenserStatus(ctx context.Context, port serialTransport) ([]byte, error) { return queryStatus(ctx, port, []byte{0x02, 'A', 'P'}, 4, delay) } // 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...)) } func cardToEncoderPosition(ctx context.Context, port serialTransport) error { log.Println("Send card to encoder position") return dispatchCommand(ctx, port, commandFC7, delay) } func resetDispenser(ctx context.Context, port serialTransport) error { return dispatchCommand(ctx, port, commandRS, delay) } func cardOutOfMouth(ctx context.Context, port serialTransport) (bool, error) { log.Println("Send card to out mouth position") if err := sendAndReadACK(ctx, port, createPacket(Address, commandFC0), delay); err != nil { return false, err } return writePacketAttempt(ctx, port, append([]byte{ENQ}, Address...)) }