From ebe50b17a941ef7dd4aa20530283e6a48ad95511 Mon Sep 17 00:00:00 2001 From: yurii Date: Mon, 2 Feb 2026 17:15:25 +0000 Subject: [PATCH] added contionuous polling of the dispenser status every 8 seconds to update the card well status --- dispenser/dispenser.go | 296 ++++++++++-------------------- dispenser/dispenserclient.go | 341 +++++++++++++++++++++++++++++++++++ handlers/handlers.go | 68 ++++--- main.go | 81 ++++++--- release notes.md | 3 + 5 files changed, 540 insertions(+), 249 deletions(-) create mode 100644 dispenser/dispenserclient.go diff --git a/dispenser/dispenser.go b/dispenser/dispenser.go index c6019dd..2186a77 100644 --- a/dispenser/dispenser.go +++ b/dispenser/dispenser.go @@ -1,11 +1,8 @@ package dispenser import ( - // "encoding/hex" "fmt" "strings" - - // "log" "time" log "github.com/sirupsen/logrus" @@ -22,29 +19,25 @@ const ( space = 0x00 // Space character baudRate = 9600 // Baud rate for serial communication delay = 500 * time.Millisecond // Delay for processing commands -) -// type ( -// configRec struct { -// SerialPort string `yaml:"port"` -// Address string `yaml:"addr"` -// } -// ) + // cache freshness for "continuous status" reads (tune as you wish) + defaultStatusTTL = 1500 * time.Millisecond +) var ( SerialPort string Address []byte - commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" command dispense card at read card position - commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" command dispense card out of card mouth command + + commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" + commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" statusPos0 = map[byte]string{ 0x38: "Keep", 0x34: "Command cannot execute", 0x32: "Preparing card fails", 0x31: "Preparing card", - 0x30: "Normal", // Default if none of the above + 0x30: "Normal", } - statusPos1 = map[byte]string{ 0x38: "Dispensing card", 0x34: "Capturing card", @@ -52,7 +45,6 @@ var ( 0x31: "Capture card error", 0x30: "Normal", } - statusPos2 = map[byte]string{ 0x38: "No captured card", 0x34: "Card overlapped", @@ -60,7 +52,6 @@ var ( 0x31: "Card pre-empty", 0x30: "Normal", } - statusPos3 = map[byte]string{ 0x38: "Card empty", 0x34: "Card ready position", @@ -71,8 +62,16 @@ var ( } ) +// -------------------- +// Status helpers +// -------------------- + func logStatus(statusBytes []byte) { - // For each position, get the ASCII character, hex value, and mapped meaning. + if len(statusBytes) < 4 { + log.Infof("Dispenser status: ", len(statusBytes)) + return + } + posStatus := []struct { pos int value byte @@ -88,65 +87,52 @@ func logStatus(statusBytes []byte) { for _, p := range posStatus { statusMsg, exists := p.mapper[p.value] if !exists { - statusMsg = fmt.Sprintf("Unknown status 0x%X;", p.value) + statusMsg = fmt.Sprintf("Unknown status 0x%X", p.value) } if p.value != 0x30 { result.WriteString(statusMsg + "; ") } } - log.Infof("Dispenser status: %s", result.String()) } func isAtEncoderPosition(statusBytes []byte) bool { - if statusBytes == nil { - return false - } - switch statusBytes[3] { - case 0x33: // Card at encoder position - return true - default: - return false // Not at encoder position - } + return len(statusBytes) >= 4 && statusBytes[3] == 0x33 } func stockTake(statusBytes []byte) string { - status := "" - if statusBytes == nil { - return status + if len(statusBytes) < 4 { + return "" } + status := "" if statusBytes[2] != 0x30 { status = statusPos2[statusBytes[2]] } - if statusBytes[3] == 0x38 { // Card well empty + if statusBytes[3] == 0x38 { status = statusPos3[statusBytes[3]] } return status } func isCardWellEmpty(statusBytes []byte) bool { - if statusBytes == nil { - return false - } - switch statusBytes[3] { - case 0x38: // Card well empty - return true - default: - return false - } + return len(statusBytes) >= 4 && statusBytes[3] == 0x38 } func checkACK(statusResp []byte) error { - if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] { + if len(statusResp) == 3 && + statusResp[0] == ACK && + len(Address) >= 2 && + statusResp[1] == Address[0] && + statusResp[2] == Address[1] { return nil - } else if len(statusResp) > 0 && statusResp[0] == NAK { - return fmt.Errorf("negative response from dispenser") - } else { - return fmt.Errorf("unexpected response status: % X", statusResp) } + if len(statusResp) > 0 && statusResp[0] == NAK { + return fmt.Errorf("negative response from dispenser") + } + return fmt.Errorf("unexpected response status: % X", statusResp) } -// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX. +// calculateBCC computes BCC as XOR of all bytes from STX to ETX. func calculateBCC(data []byte) byte { var bcc byte for _, b := range data { @@ -157,8 +143,8 @@ func calculateBCC(data []byte) byte { func createPacket(address []byte, command []byte) []byte { packet := []byte{STX} - packet = append(packet, address...) // Address bytes - packet = append(packet, space) // Space character + packet = append(packet, address...) + packet = append(packet, space) packet = append(packet, command...) packet = append(packet, ETX) bcc := calculateBCC(packet) @@ -166,230 +152,134 @@ func createPacket(address []byte, command []byte) []byte { return packet } -func buildCheckRF(address []byte) []byte { - return createPacket(address, []byte{STX, 0x52, 0x46}) -} - -func buildCheckAP(address []byte) []byte { - return createPacket(address, []byte{STX, 0x41, 0x50}) -} +func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) } func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) { - n, err := port.Write(packet) + _, err := port.Write(packet) if err != nil { return nil, fmt.Errorf("error writing to port: %w", err) } - // log.Printf("TX %d bytes: % X", n, packet[:n]) - time.Sleep(delay) // Wait for the dispenser to process the command + time.Sleep(delay) buf := make([]byte, 128) - n, err = port.Read(buf) + n, err := port.Read(buf) if err != nil { return nil, fmt.Errorf("error reading from port: %w", err) } - resp := buf[:n] - // log.Printf("RX %d bytes: % X", n, buf[:n]) - return resp, nil + return buf[:n], nil } +// -------------------- +// Serial init (3 attempts) +// -------------------- + func InitializeDispenser() (*serial.Port, error) { - const funcName = "initializeDispenser" + const ( + funcName = "InitializeDispenser" + maxRetries = 3 + retryDelay = 2 * 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: time.Second * 2, + ReadTimeout: 2 * time.Second, } - port, err := serial.OpenPort(serialConfig) - if err != nil { - return nil, fmt.Errorf("error opening dispenser COM port: %w", err) + + 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 port, nil + + return nil, fmt.Errorf("%s: failed to open dispenser on %s after %d attempts: %w", funcName, SerialPort, maxRetries, lastErr) } -func DispenserPrepare(port *serial.Port) (string, error) { - const funcName = "dispenserSequence" - stockStatus := "" - // Check dispenser status - status, err := CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - if isCardWellEmpty(status) { - return stockStatus, nil - } +// -------------------- +// Internal (port-owner only) operations +// -------------------- - if isAtEncoderPosition(status) { - return stockStatus, nil - } - // Send card to encoder position - err = CardToEncoderPosition(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err) - } - - time.Sleep(delay) - // Check dispenser status - status, err = CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - - return stockStatus, nil -} - -func DispenserStart(port *serial.Port) (string, error) { - const funcName = "dispenserSequence" - stockStatus := "" - // Check dispenser status - status, err := CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - if isCardWellEmpty(status) { - return stockStatus, fmt.Errorf(stockStatus) - } - - if isAtEncoderPosition(status) { - return stockStatus, nil - } - // Send card to encoder position - err = CardToEncoderPosition(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err) - } - - time.Sleep(delay) - // Check dispenser status - status, err = CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - - return stockStatus, nil -} - -func DispenserFinal(port *serial.Port) (string, error) { - const funcName = "dispenserSequence" - stockStatus := "" - - err := CardOutOfMouth(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error sending card to out mouth position: %v", funcName, err) - } - time.Sleep(delay) - // Check dispenser status - status, err := CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - - time.Sleep(delay) - // Send card to encoder position - err = CardToEncoderPosition(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err) - } - - time.Sleep(delay) - // Check dispenser status - status, err = CheckDispenserStatus(port) - if err != nil { - return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err) - } - logStatus(status) - stockStatus = stockTake(status) - - return stockStatus, nil -} - -// if dispenser is not responding, I should repeat the command -func CheckDispenserStatus(port *serial.Port) ([]byte, error) { - const funcName = "checkDispenserStatus" +// 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...) - // Send check command (AP) statusResp, err := sendAndReceive(port, checkCmd, delay) if err != nil { - return nil, fmt.Errorf("error sending check command: %v", err) + return nil, fmt.Errorf("error sending check command: %w", err) } if len(statusResp) == 0 { return nil, fmt.Errorf("no response from dispenser") } - err = checkACK(statusResp) - if err != nil { + if err := checkACK(statusResp); err != nil { return nil, err } - // Send ENQ+ADDR to prompt device to execute the command. statusResp, err = sendAndReceive(port, enq, delay) if err != nil { - log.Errorf("error sending ENQ: %v", err) - } - if len(statusResp) == 0 { - return nil, fmt.Errorf("no response from dispenser") + 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 // Return status bytes + return statusResp[7:11], nil } -func CardToEncoderPosition(port *serial.Port) error { - const funcName = "cartToEncoderPosition" +func cardToEncoderPosition(port *serial.Port) error { enq := append([]byte{ENQ}, Address...) - //Send Dispense card to encoder position (FC7) --- dispenseCmd := createPacket(Address, commandFC7) 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: %v", err) + return fmt.Errorf("error sending card to encoder position: %w", err) } - err = checkACK(statusResp) - if err != nil { + if err := checkACK(statusResp); err != nil { return err } - //Send ENQ to prompt device --- _, err = port.Write(enq) if err != nil { - return fmt.Errorf("error sending ENQ to prompt device: %v", err) + return fmt.Errorf("error sending ENQ to prompt device: %w", err) } return nil } -func CardOutOfMouth(port *serial.Port) error { - const funcName = "CardOutOfMouth" +func cardOutOfMouth(port *serial.Port) error { enq := append([]byte{ENQ}, Address...) - // Send card out of card mouth (FC0) --- dispenseCmd := createPacket(Address, commandFC0) 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: %v", err) + return fmt.Errorf("error sending out of mouth command: %w", err) } - err = checkACK(statusResp) - if err != nil { + if err := checkACK(statusResp); err != nil { return err } - //Send ENQ to prompt device --- _, err = port.Write(enq) if err != nil { - return fmt.Errorf("error sending ENQ to prompt device: %v", err) + return fmt.Errorf("error sending ENQ to prompt device: %w", err) } return nil } diff --git a/dispenser/dispenserclient.go b/dispenser/dispenserclient.go new file mode 100644 index 0000000..2fc9278 --- /dev/null +++ b/dispenser/dispenserclient.go @@ -0,0 +1,341 @@ +// -------------------- +// Queue-based client (single owner of port) +// -------------------- +package dispenser + +import ( + "context" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/tarm/serial" +) + +type cmdType int + +const ( + cmdStatus cmdType = iota + cmdToEncoder + cmdOutOfMouth +) + +type cmdReq struct { + typ cmdType + ctx context.Context + respCh chan cmdResp +} + +type cmdResp struct { + status []byte + err error +} + +type Client struct { + port *serial.Port + + reqCh chan cmdReq + done chan struct{} + + // status cache + mu sync.RWMutex + lastStatus []byte + lastStatusT time.Time + statusTTL time.Duration + + // published "stock/cardwell" cache + callback + lastStockMu sync.RWMutex + lastStock string + onStock func(string) +} + +// NewClient starts the worker that owns the serial port. +func NewClient(port *serial.Port, queueSize int) *Client { + if queueSize <= 0 { + queueSize = 16 + } + c := &Client{ + port: port, + reqCh: make(chan cmdReq, queueSize), + done: make(chan struct{}), + statusTTL: defaultStatusTTL, + } + go c.loop() + return c +} + +func (c *Client) Close() { + select { + case <-c.done: + return + default: + close(c.done) + } +} + +// Optional: tune cache TTL (how "fresh" cached status must be) +func (c *Client) SetStatusTTL(d time.Duration) { + c.mu.Lock() + c.statusTTL = d + c.mu.Unlock() +} + +// OnStockUpdate registers a callback called whenever polling (or status reads) produce a stock status string. +func (c *Client) OnStockUpdate(fn func(string)) { + c.lastStockMu.Lock() + c.onStock = fn + c.lastStockMu.Unlock() +} + +// LastStock returns the most recently computed stock/card-well status string. +func (c *Client) LastStock() string { + c.lastStockMu.RLock() + defer c.lastStockMu.RUnlock() + return c.lastStock +} + +func (c *Client) setStock(statusBytes []byte) { + stock := stockTake(statusBytes) + + c.lastStockMu.Lock() + c.lastStock = stock + fn := c.onStock + c.lastStockMu.Unlock() + + // call outside lock + if fn != nil { + fn(stock) + } +} + +// StartPolling performs a periodic status refresh. +// It will NOT interrupt commands: it enqueues only when queue is idle. +func (c *Client) StartPolling(interval time.Duration) { + if interval <= 0 { + return + } + go func() { + t := time.NewTicker(interval) + defer t.Stop() + + for { + select { + case <-c.done: + return + case <-t.C: + // enqueue only if idle to avoid delaying real commands + if len(c.reqCh) != 0 { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := c.CheckStatus(ctx) + if err != nil { + log.Debugf("dispenser polling: %v", err) + } + cancel() + } + } + }() +} + +func (c *Client) loop() { + for { + select { + case <-c.done: + return + case req := <-c.reqCh: + c.handle(req) + } + } +} + +func (c *Client) handle(req cmdReq) { + select { + case <-req.ctx.Done(): + req.respCh <- cmdResp{err: req.ctx.Err()} + return + default: + } + + 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() + + // publish stock/cardwell + c.setStock(st) + } + req.respCh <- cmdResp{status: st, err: err} + + case cmdToEncoder: + err := cardToEncoderPosition(c.port) + req.respCh <- cmdResp{err: err} + + case cmdOutOfMouth: + err := cardOutOfMouth(c.port) + req.respCh <- cmdResp{err: err} + + default: + req.respCh <- cmdResp{err: fmt.Errorf("unknown command")} + } +} + +func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) { + 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() + } + + select { + case r := <-rch: + return r.status, r.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// CheckStatus returns cached status if fresh, otherwise enqueues a device status read. +func (c *Client) CheckStatus(ctx context.Context) ([]byte, error) { + c.mu.RLock() + ttl := c.statusTTL + st := append([]byte(nil), c.lastStatus...) + ts := c.lastStatusT + c.mu.RUnlock() + + if len(st) == 4 && time.Since(ts) <= ttl { + // even when returning cached, keep stock in sync + c.setStock(st) + return st, nil + } + return c.do(ctx, cmdStatus) +} + +func (c *Client) ToEncoder(ctx context.Context) error { + _, err := c.do(ctx, cmdToEncoder) + return err +} + +func (c *Client) OutOfMouth(ctx context.Context) error { + _, err := c.do(ctx, cmdOutOfMouth) + return err +} + +// -------------------- +// Public sequences updated to use Client (queue) +// -------------------- + +// DispenserPrepare: check status; if empty => ok; else ensure at encoder. +func (c *Client) DispenserPrepare(ctx context.Context) (string, error) { + const funcName = "DispenserPrepare" + stockStatus := "" + + status, err := c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err) + } + + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + if isCardWellEmpty(status) { + return stockStatus, nil + } + if isAtEncoderPosition(status) { + return stockStatus, nil + } + + if err := c.ToEncoder(ctx); err != nil { + return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) + } + + time.Sleep(delay) + status, err = c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err) + } + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + return stockStatus, nil +} + +func (c *Client) DispenserStart(ctx context.Context) (string, error) { + const funcName = "DispenserStart" + stockStatus := "" + + status, err := c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err) + } + + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + if isCardWellEmpty(status) { + return stockStatus, fmt.Errorf(stockStatus) + } + if isAtEncoderPosition(status) { + return stockStatus, nil + } + + if err := c.ToEncoder(ctx); err != nil { + return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) + } + + time.Sleep(delay) + status, err = c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err) + } + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + return stockStatus, nil +} + +func (c *Client) DispenserFinal(ctx context.Context) (string, error) { + const funcName = "DispenserFinal" + stockStatus := "" + + if err := c.OutOfMouth(ctx); err != nil { + return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err) + } + + time.Sleep(delay) + status, err := c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err) + } + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + time.Sleep(delay) + if err := c.ToEncoder(ctx); err != nil { + return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) + } + + time.Sleep(delay) + status, err = c.CheckStatus(ctx) + if err != nil { + return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err) + } + logStatus(status) + stockStatus = stockTake(status) + c.setStock(status) + + return stockStatus, nil +} diff --git a/handlers/handlers.go b/handlers/handlers.go index b0be399..bf5ed23 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -12,8 +12,6 @@ import ( "sync" "time" - "github.com/tarm/serial" - "gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser" @@ -27,24 +25,26 @@ import ( ) type App struct { - dispPort *serial.Port + disp *dispenser.Client lockserver lockserver.LockServer isPayment bool db *sql.DB cfg *config.ConfigRec dbMu sync.Mutex + cardWellMu sync.RWMutex cardWellStatus string } -func NewApp(dispPort *serial.Port, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App { - return &App{ +func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App { + app := &App{ isPayment: cfg.IsPayment, - dispPort: dispPort, + disp: disp, lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError), db: db, cfg: cfg, - cardWellStatus: cardWellStatus, } + app.SetCardWellStatus(cardWellStatus) + return app } func (app *App) RegisterRoutes(mux *http.ServeMux) { @@ -281,37 +281,51 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { return } - if app.cardWellStatus, err = dispenser.DispenserStart(app.dispPort); err != nil { + // Ensure dispenser ready (card at encoder) BEFORE we attempt encoding. + // With queued dispenser ops, this will not clash with polling. + status, err := app.disp.DispenserStart(r.Context()) + app.SetCardWellStatus(status) + if err != nil { logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0) errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) return } + // Always attempt to finalize after we have moved a card / started an issuance flow. + // This guarantees we eject and prepare the next card even on lock failures. + finalize := func() { + if app.disp == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + status, ferr := app.disp.DispenserFinal(ctx) + if ferr != nil { + logging.Error(serviceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0) + return + } + app.SetCardWellStatus(status) + } + // build lock server command app.lockserver.BuildCommand(doorReq, checkIn, checkOut) // lock server sequence - err = app.lockserver.LockSequence() - if err != nil { + if err := app.lockserver.LockSequence(); err != nil { logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0) - dispenser.DispenserFinal(app.dispPort) + finalize() errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) return } // final dispenser steps - if app.cardWellStatus, err = dispenser.DispenserFinal(app.dispPort); err != nil { - logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0) - errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error()) - return - } + finalize() theResponse.Code = http.StatusOK theResponse.Message = "Card issued successfully" - // success! return 200 and any data you like - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(theResponse) + _ = json.NewEncoder(w).Encode(theResponse) } func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { @@ -369,8 +383,20 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(cmstypes.StatusRec{ + _ = json.NewEncoder(w).Encode(cmstypes.StatusRec{ Code: http.StatusOK, - Message: app.cardWellStatus, + Message: app.CardWellStatus(), }) } + +func (app *App) SetCardWellStatus(s string) { + app.cardWellMu.Lock() + app.cardWellStatus = s + app.cardWellMu.Unlock() +} + +func (app *App) CardWellStatus() string { + app.cardWellMu.RLock() + defer app.cardWellMu.RUnlock() + return app.cardWellStatus +} diff --git a/main.go b/main.go index 7c4b6c9..b26d3d3 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/xml" "fmt" "net/http" @@ -26,68 +27,82 @@ import ( ) const ( - buildVersion = "1.1.0" - serviceName = "hardlink" + buildVersion = "1.1.1" + serviceName = "hardlink" + pollingFrequency = 8 * time.Second ) func main() { // Load config - config := config.ReadHardlinkConfig() + cfg := config.ReadHardlinkConfig() printer.Layout = readTicketLayout() - printer.PrinterName = config.PrinterName - lockserver.Cert = config.Cert - lockserver.LockServerURL = config.LockserverUrl - dispHandle := &serial.Port{} - cardWellStatus := "" + printer.PrinterName = cfg.PrinterName + lockserver.Cert = cfg.Cert + lockserver.LockServerURL = cfg.LockserverUrl + + var ( + dispPort *serial.Port + disp *dispenser.Client + cardWellStatus string + ) // Setup logging and get file handle - logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion) + logFile, err := logging.SetupLogging(cfg.LogDir, serviceName, buildVersion) if err != nil { log.Printf("Failed to set up logging: %v\n", err) } defer logFile.Close() // Initialize dispenser - if !config.TestMode { - dispenser.SerialPort = config.DispenserPort - dispenser.Address = []byte(config.DispenserAdrr) - dispHandle, err = dispenser.InitializeDispenser() + if !cfg.TestMode { + dispenser.SerialPort = cfg.DispenserPort + dispenser.Address = []byte(cfg.DispenserAdrr) + + dispPort, err = dispenser.InitializeDispenser() if err != nil { errorhandlers.FatalError(err) } - defer dispHandle.Close() + defer dispPort.Close() - cardWellStatus, err = dispenser.DispenserPrepare(dispHandle) + // Start queued dispenser client (single goroutine owns the serial port) + disp = dispenser.NewClient(dispPort, 32) + defer disp.Close() + + // Prepare dispenser (ensures card at encoder position unless empty) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + cardWellStatus, err = disp.DispenserPrepare(ctx) if err != nil { - err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr) + err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr) errorhandlers.FatalError(err) } fmt.Println(cardWellStatus) } // Test lock-server connection - switch strings.ToLower(config.LockType) { + switch strings.ToLower(cfg.LockType) { case lockserver.TLJ: - + // TLJ uses HTTP - skip TCP probe here (as you did before) default: - lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl) + lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) if err != nil { fmt.Println(err.Error()) log.Errorf(err.Error()) } else { - fmt.Printf("Connected to the lock server successfuly at %s\n", config.LockserverUrl) - log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl) + fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverUrl) + log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverUrl) lockConn.Close() } } - database, err := bootstrap.OpenDB(&config) + database, err := bootstrap.OpenDB(&cfg) if err != nil { log.Warnf("DB init failed: %v", err) } defer database.Close() - if config.IsPayment { + if cfg.IsPayment { fmt.Println("Payment processing is enabled") log.Info("Payment processing is enabled") startChipDnaClient() @@ -97,14 +112,30 @@ func main() { } // Create App and wire routes - app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, cardWellStatus, database, &config) + // NOTE: change handlers.NewApp signature to accept *dispenser.Client instead of *serial.Port + app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg) + + // Update cardWellStatus when dispenser status changes + if !cfg.TestMode { + // Set initial cardWellStatus + app.SetCardWellStatus(cardWellStatus) + + // Set up callback to update cardWellStatus when dispenser status changes + disp.OnStockUpdate(func(stock string) { + app.SetCardWellStatus(stock) + }) + + // Start polling for dispenser status every 10 seconds + disp.StartPolling(pollingFrequency) + } mux := http.NewServeMux() app.RegisterRoutes(mux) - addr := fmt.Sprintf(":%d", config.Port) + addr := fmt.Sprintf(":%d", cfg.Port) log.Infof("Starting HTTP server on http://localhost%s", addr) fmt.Printf("Starting HTTP server on http://localhost%s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { errorhandlers.FatalError(err) } diff --git a/release notes.md b/release notes.md index 2090d24..f597df1 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.1.1 - 02 February 2026 +added contionuous polling of the dispenser status every 8 seconds to update the card well status + #### 1.1.0 - 26 January 2026 divided `/starttransaction` endpoint into two separate endpoints: `/takepreauth` to request preauthorization payment