417 lines
12 KiB
Go
417 lines
12 KiB
Go
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)
|
|
|
|
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("<invalid len=%d>", 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
|
|
}
|
|
|
|
func validateDispenserStatusData(statusBytes []byte) error {
|
|
if len(statusBytes) != 4 {
|
|
return fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(statusBytes))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func dispenserStatusError(statusBytes []byte) error {
|
|
switch statusBytes[0] {
|
|
case 0x34, 0x32, 0x36:
|
|
return fmt.Errorf("dispenser error: %s", statusPos0[statusBytes[0]])
|
|
}
|
|
switch statusBytes[1] {
|
|
case 0x32, 0x31:
|
|
return fmt.Errorf("dispenser error: %s", statusPos1[statusBytes[1]])
|
|
}
|
|
switch statusBytes[2] {
|
|
case 0x34, 0x32:
|
|
return fmt.Errorf("dispenser error: %s", statusPos2[statusBytes[2]])
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isPreparationMoving(statusBytes []byte) bool {
|
|
return len(statusBytes) == 4 &&
|
|
(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 ""
|
|
}
|
|
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 {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
|
|
// --------------------
|
|
// 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) error {
|
|
log.Println("Send card to out mouth position")
|
|
return dispatchCommand(ctx, port, commandFC0, delay)
|
|
}
|