Compare commits

..

No commits in common. "development" and "1.0.30" have entirely different histories.

11 changed files with 301 additions and 930 deletions

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"strings" "strings"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/handlers"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3" yaml "gopkg.in/yaml.v3"
) )
@ -49,7 +49,7 @@ func ReadHardlinkConfig() ConfigRec {
if cfg.LockType == "" { if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName) err = fmt.Errorf("LockType is required in %s", configName)
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
cfg.LockType = strings.ToLower(cfg.LockType) cfg.LockType = strings.ToLower(cfg.LockType)
@ -81,7 +81,7 @@ func ReadPreauthReleaserConfig() ConfigRec {
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" { if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName) err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
if cfg.LogDir == "" { if cfg.LogDir == "" {

View File

@ -198,4 +198,3 @@ WHERE TxnReference = @TxnReference AND Released = 0;
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339)) log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
return nil return nil
} }

View File

@ -1,8 +1,9 @@
package dispenser package dispenser
import ( import (
// "encoding/hex"
"fmt" "fmt"
"strings" // "log"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -19,25 +20,29 @@ const (
space = 0x00 // Space character space = 0x00 // Space character
baudRate = 9600 // Baud rate for serial communication baudRate = 9600 // Baud rate for serial communication
delay = 500 * time.Millisecond // Delay for processing commands delay = 500 * time.Millisecond // Delay for processing commands
// cache freshness for "continuous status" reads (tune as you wish)
defaultStatusTTL = 1500 * time.Millisecond
) )
// type (
// configRec struct {
// SerialPort string `yaml:"port"`
// Address string `yaml:"addr"`
// }
// )
var ( var (
SerialPort string SerialPort string
Address []byte Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" command dispense card at read card position
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" command dispense card out of card mouth command
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
statusPos0 = map[byte]string{ statusPos0 = map[byte]string{
0x38: "Keep", 0x38: "Keep",
0x34: "Command cannot execute", 0x34: "Command cannot execute",
0x32: "Preparing card fails", 0x32: "Preparing card fails",
0x31: "Preparing card", 0x31: "Preparing card",
0x30: "Normal", 0x30: "Normal", // Default if none of the above
} }
statusPos1 = map[byte]string{ statusPos1 = map[byte]string{
0x38: "Dispensing card", 0x38: "Dispensing card",
0x34: "Capturing card", 0x34: "Capturing card",
@ -45,6 +50,7 @@ var (
0x31: "Capture card error", 0x31: "Capture card error",
0x30: "Normal", 0x30: "Normal",
} }
statusPos2 = map[byte]string{ statusPos2 = map[byte]string{
0x38: "No captured card", 0x38: "No captured card",
0x34: "Card overlapped", 0x34: "Card overlapped",
@ -52,6 +58,7 @@ var (
0x31: "Card pre-empty", 0x31: "Card pre-empty",
0x30: "Normal", 0x30: "Normal",
} }
statusPos3 = map[byte]string{ statusPos3 = map[byte]string{
0x38: "Card empty", 0x38: "Card empty",
0x34: "Card ready position", 0x34: "Card ready position",
@ -62,77 +69,48 @@ var (
} }
) )
// -------------------- func checkStatus(statusResp []byte) (string, error) {
// Status helpers if len(statusResp) > 3 {
// -------------------- statusBytes := statusResp[7:11] // Extract the relevant bytes from the response
// For each position, get the ASCII character, hex value, and mapped meaning.
func logStatus(statusBytes []byte) { posStatus := []struct {
if len(statusBytes) < 4 { pos int
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes)) value byte
return mapper map[byte]string
} }{
{pos: 1, value: statusBytes[0], mapper: statusPos0},
posStatus := []struct { {pos: 2, value: statusBytes[1], mapper: statusPos1},
pos int {pos: 3, value: statusBytes[2], mapper: statusPos2},
value byte {pos: 4, value: statusBytes[3], mapper: statusPos3},
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},
{pos: 4, value: statusBytes[3], mapper: statusPos3},
}
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 + "; ") result := ""
for _, p := range posStatus {
statusMsg, exists := p.mapper[p.value]
if !exists {
statusMsg = "Unknown status"
}
if p.value != 0x30 {
result += fmt.Sprintf("Status: %s; ", statusMsg)
}
if p.pos == 4 && p.value == 0x38 {
return result, fmt.Errorf("Card well empty")
}
}
return result, nil
} else {
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
return "active;", 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)
} }
} }
log.Infof("Dispenser status: %s", result.String())
} }
func isAtEncoderPosition(statusBytes []byte) bool { // calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
}
func stockTake(statusBytes []byte) string {
if len(statusBytes) < 4 {
return ""
}
status := ""
if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]]
}
if statusBytes[3] == 0x38 {
status = statusPos3[statusBytes[3]]
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x38
}
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 { func calculateBCC(data []byte) byte {
var bcc byte var bcc byte
for _, b := range data { for _, b := range data {
@ -143,8 +121,8 @@ func calculateBCC(data []byte) byte {
func createPacket(address []byte, command []byte) []byte { func createPacket(address []byte, command []byte) []byte {
packet := []byte{STX} packet := []byte{STX}
packet = append(packet, address...) packet = append(packet, address...) // Address bytes
packet = append(packet, space) packet = append(packet, space) // Space character
packet = append(packet, command...) packet = append(packet, command...)
packet = append(packet, ETX) packet = append(packet, ETX)
bcc := calculateBCC(packet) bcc := calculateBCC(packet)
@ -152,134 +130,165 @@ func createPacket(address []byte, command []byte) []byte {
return packet return packet
} }
func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) } 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 sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) { func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
_, err := port.Write(packet) n, err := port.Write(packet)
if err != nil { if err != nil {
return nil, fmt.Errorf("error writing to port: %w", err) 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
buf := make([]byte, 128)
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
}
func InitializeDispenser() (*serial.Port, error) {
const funcName = "initializeDispenser"
serialConfig := &serial.Config{
Name: SerialPort,
Baud: baudRate,
ReadTimeout: time.Second * 2,
}
port, err := serial.OpenPort(serialConfig)
if err != nil {
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
}
return port, nil
}
func DispenserSequence(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
var result string
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
result += status
// Send card to encoder position
status, err = CardToEncoderPosition(port)
if err != nil {
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
result += "; " + status
return result, nil
}
// if dispenser is not responding, I should repeat the command
func CheckDispenserStatus(port *serial.Port) (string, error) {
const funcName = "checkDispenserStatus"
var result string
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
// Send check command (AP)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending check command: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
}
status, err := checkStatus(statusResp)
if err != nil {
return status, err
}
result += "; " + status
// 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 "", fmt.Errorf("no response from dispenser")
}
status, err = checkStatus(statusResp)
if err != nil {
return status, err
}
result += status
return result, nil
}
func CardToEncoderPosition(port *serial.Port) (string, error) {
const funcName = "cartToEncoderPosition"
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)
}
_, err = checkStatus(statusResp)
if 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)
}
time.Sleep(delay) time.Sleep(delay)
buf := make([]byte, 128) //Check card position status
n, err := port.Read(buf) status, err := CheckDispenserStatus(port)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err) return "", err
} }
return buf[:n], nil return status, nil
} }
// -------------------- func CardOutOfMouth(port *serial.Port) (string, error) {
// Serial init (3 attempts) const funcName = "CardOutOfMouth"
// --------------------
func InitializeDispenser() (*serial.Port, error) {
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: 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(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 cardToEncoderPosition(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
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: %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
}
func cardOutOfMouth(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...) enq := append([]byte{ENQ}, Address...)
// Send card out of card mouth (FC0) ---
dispenseCmd := createPacket(Address, commandFC0) dispenseCmd := createPacket(Address, commandFC0)
log.Println("Send card to out mouth position") log.Println("Send card to out mouth position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay) statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil { if err != nil {
return fmt.Errorf("error sending out of mouth command: %w", err) return "", fmt.Errorf("error sending out of mouth command: %v", err)
} }
if err := checkACK(statusResp); err != nil { _, err = checkStatus(statusResp)
return err if err != nil {
return "", err
} }
//Send ENQ to prompt device ---
_, err = port.Write(enq) _, err = port.Write(enq)
if err != nil { if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err) return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
} }
return nil
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
return "", err
}
return status, nil
} }

View File

@ -1,341 +0,0 @@
// --------------------
// 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
}

View File

@ -1,32 +0,0 @@
package errorhandlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"gitea.futuresens.co.uk/futuresens/cmstypes"
log "github.com/sirupsen/logrus"
)
const serviceName = "hardlink"
// writeError is a helper to send a JSON error and HTTP status in one go.
func WriteError(w http.ResponseWriter, status int, msg string) {
theResponse := cmstypes.StatusRec{
Code: status,
Message: msg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(theResponse)
}
func FatalError(err error) {
fmt.Println(err.Error())
log.Errorf(err.Error())
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(1)
}

View File

@ -1,206 +0,0 @@
package handlers
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
log "github.com/sirupsen/logrus"
)
type preauthSpoolRecord struct {
CreatedAt time.Time `json:"createdAt"`
CheckoutDate string `json:"checkoutDate"` // keep as received
Fields map[string]string `json:"fields"` // ChipDNA result.Fields
}
func (app *App) getDB(ctx context.Context) (*sql.DB, error) {
app.dbMu.Lock()
defer app.dbMu.Unlock()
// Fast path: db exists and is alive
if app.db != nil {
pingCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
if err := app.db.PingContext(pingCtx); err == nil {
return app.db, nil
}
// stale handle
_ = app.db.Close()
app.db = nil
}
// Reconnect once, bounded
dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
dbConn, err := db.InitMSSQL(
app.cfg.Dbport,
app.cfg.Dbuser,
app.cfg.Dbpassword,
app.cfg.Dbname,
)
if err != nil {
return nil, err
}
pingCtx, cancel2 := context.WithTimeout(dialCtx, 1*time.Second)
defer cancel2()
if err := dbConn.PingContext(pingCtx); err != nil {
_ = dbConn.Close()
return nil, err
}
app.db = dbConn
return app.db, nil
}
func (app *App) spoolPath() string {
// keep it near logs; adjust if you prefer a dedicated dir
// ensure LogDir ends with separator in your config loader
return filepath.Join(app.cfg.LogDir, "preauth_spool.ndjson")
}
// persistPreauth tries DB first; if DB is down or insert fails, it spools to file.
// It never returns an error to the caller (so your HTTP flow stays simple),
// but it logs failures.
func (app *App) persistPreauth(ctx context.Context, fields map[string]string, checkoutDate string) {
// First, try DB (with your reconnect logic inside getDB)
dbConn, err := app.getDB(ctx)
if err == nil && dbConn != nil {
if err := db.InsertPreauth(ctx, dbConn, fields, checkoutDate); err == nil {
// opportunistic drain once DB is alive
go app.drainPreauthSpool(context.Background())
return
} else {
log.WithError(err).Warn("DB insert failed; will spool preauth")
}
} else {
log.WithError(err).Warn("DB unavailable; will spool preauth")
}
// Fallback: spool to file
rec := preauthSpoolRecord{
CreatedAt: time.Now().UTC(),
CheckoutDate: checkoutDate,
Fields: fields,
}
if spErr := app.spoolPreauth(rec); spErr != nil {
log.WithError(spErr).Error("failed to spool preauth")
}
}
// append one line JSON (NDJSON)
func (app *App) spoolPreauth(rec preauthSpoolRecord) error {
p := app.spoolPath()
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return fmt.Errorf("open spool file: %w", err)
}
defer f.Close()
b, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal spool record: %w", err)
}
if _, err := f.Write(append(b, '\n')); err != nil {
return fmt.Errorf("write spool record: %w", err)
}
return f.Sync() // ensure it's on disk
}
// Drain spool into DB.
// Strategy: read all lines, insert each; keep failures in a temp file; then replace original.
func (app *App) drainPreauthSpool(ctx context.Context) {
dbConn, err := app.getDB(ctx)
if err != nil {
return // still down, nothing to do
}
spool := app.spoolPath()
in, err := os.Open(spool)
if err != nil {
// no spool is fine
return
}
defer in.Close()
tmp := spool + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
if err != nil {
log.WithError(err).Warn("drain spool: open tmp failed")
return
}
defer out.Close()
sc := bufio.NewScanner(in)
// allow long lines if receipts ever sneak in (shouldn't, but safe)
buf := make([]byte, 0, 64*1024)
sc.Buffer(buf, 2*1024*1024)
var (
okCount int
failCount int
)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var rec preauthSpoolRecord
if err := json.Unmarshal(line, &rec); err != nil {
// malformed line: keep it so we don't lose evidence
_, _ = out.Write(append(line, '\n'))
failCount++
continue
}
// attempt insert
if err := db.InsertPreauth(ctx, dbConn, rec.Fields, rec.CheckoutDate); err != nil {
// DB still flaky or data issue: keep it for later retry
_, _ = out.Write(append(line, '\n'))
failCount++
continue
}
okCount++
}
if err := sc.Err(); err != nil {
log.WithError(err).Warn("drain spool: scanner error")
// best effort; do not replace spool
return
}
_ = out.Sync()
// Replace original spool with temp (atomic on Windows is best-effort; still OK here)
_ = in.Close()
_ = out.Close()
if err := os.Rename(tmp, spool); err != nil {
log.WithError(err).Warn("drain spool: rename failed")
return
}
if okCount > 0 || failCount > 0 {
log.WithFields(log.Fields{
"inserted": okCount,
"remaining": failCount,
}).Info("preauth spool drained")
}
}

View File

@ -2,20 +2,19 @@ package handlers
import ( import (
"bytes" "bytes"
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/tarm/serial"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/payment" "gitea.futuresens.co.uk/futuresens/hardlink/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/printer" "gitea.futuresens.co.uk/futuresens/hardlink/printer"
@ -25,26 +24,19 @@ import (
) )
type App struct { type App struct {
disp *dispenser.Client dispPort *serial.Port
lockserver lockserver.LockServer lockserver lockserver.LockServer
isPayment bool isPayment bool
db *sql.DB db *sql.DB
cfg *config.ConfigRec
dbMu sync.Mutex
cardWellMu sync.RWMutex
cardWellStatus string
} }
func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App { func NewApp(dispPort *serial.Port, lockType, encoderAddress string, db *sql.DB, isPayment bool) *App {
app := &App{ return &App{
isPayment: cfg.IsPayment, isPayment: isPayment,
disp: disp, dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError), lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
db: db, db: db,
cfg: cfg,
} }
app.SetCardWellStatus(cardWellStatus)
return app
} }
func (app *App) RegisterRoutes(mux *http.ServeMux) { func (app *App) RegisterRoutes(mux *http.ServeMux) {
@ -52,7 +44,6 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/printroomticket", app.printRoomTicket) mux.HandleFunc("/printroomticket", app.printRoomTicket)
mux.HandleFunc("/takepreauth", app.takePreauthorization) mux.HandleFunc("/takepreauth", app.takePreauthorization)
mux.HandleFunc("/takepayment", app.takePayment) mux.HandleFunc("/takepayment", app.takePayment)
mux.HandleFunc("/dispenserstatus", app.reportDispenserStatus)
} }
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
@ -140,9 +131,8 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields) theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save { if save {
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) db.InsertPreauth(r.Context(), app.db, result.Fields, theRequest.CheckoutDate)
} }
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
@ -251,19 +241,19 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
log.Println("issueDoorCard called") log.Println("issueDoorCard called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST") writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return return
} }
defer r.Body.Close() defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "application/json" { if ct := r.Header.Get("Content-Type"); ct != "application/json" {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return return
} }
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil { if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return return
} }
@ -271,61 +261,57 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime) checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return return
} }
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime) checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return return
} }
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding. // dispenser sequence
// With queued dispenser ops, this will not clash with polling. if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
status, err := app.disp.DispenserStart(r.Context()) if status != "" {
app.SetCardWellStatus(status) logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
if err != nil { writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0) } else {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
}
return return
} } else {
log.Info(status)
// 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 // build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut) app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence // lock server sequence
if err := app.lockserver.LockSequence(); err != nil { err = app.lockserver.LockSequence()
if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
finalize() writeError(w, http.StatusBadGateway, err.Error())
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) dispenser.CardOutOfMouth(app.dispPort)
return return
} }
// final dispenser steps // final dispenser steps
finalize() if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
return
} else {
log.Info(status)
}
theResponse.Code = http.StatusOK theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully" theResponse.Message = "Card issued successfully"
// success! return 200 and any data you like
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(theResponse) json.NewEncoder(w).Encode(theResponse)
} }
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
@ -342,32 +328,32 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
} }
log.Println("printRoomTicket called") log.Println("printRoomTicket called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST") writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return return
} }
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") { if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml") writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
return return
} }
defer r.Body.Close() defer r.Body.Close()
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil { if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
return return
} }
data, err := printer.BuildRoomTicket(roomDetails) data, err := printer.BuildRoomTicket(roomDetails)
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error()) writeError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
return return
} }
// Send to the Windows Epson TM-T82II via the printer package // Send to the Windows Epson TM-T82II via the printer package
if err := printer.SendToPrinter(data); err != nil { if err := printer.SendToPrinter(data); err != nil {
logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0) logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error()) writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
return return
} }
@ -379,24 +365,3 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
Message: "Print job sent successfully", Message: "Print job sent successfully",
}) })
} }
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{
Code: http.StatusOK,
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
}

View File

@ -2,14 +2,28 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/logging" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
) )
const serviceName = "hardlink" const serviceName = "hardlink"
// writeError is a helper to send a JSON error and HTTP status in one go.
func writeError(w http.ResponseWriter, status int, msg string) {
theResponse := cmstypes.StatusRec{
Code: status,
Message: msg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(theResponse)
}
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) { func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
@ -18,3 +32,10 @@ func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmsty
} }
} }
func FatalError(err error) {
fmt.Println(err.Error())
log.Errorf(err.Error())
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(1)
}

View File

@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
// 1. Send ENQ // 1. Send ENQ
log.Infof("LockSequence: sending ENQ") log.Infof("Sending ENQ")
if _, e := conn.Write([]byte{ENQ}); e != nil { if _, e := conn.Write([]byte{ENQ}); e != nil {
return fmt.Errorf("failed to send ENQ: %w", e) return fmt.Errorf("failed to send ENQ: %w", e)
} }
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
} }
// 3. Send command frame // 3. Send command frame
log.Infof("LockSequence: sending encoding command: %q", string(lock.command)) log.Infof("Sending encoding command: %q", string(lock.command))
if _, e := conn.Write(lock.command); e != nil { if _, e := conn.Write(lock.command); e != nil {
return fmt.Errorf("failed to send command frame: %w", e) return fmt.Errorf("failed to send command frame: %w", e)
} }

100
main.go
View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"net/http" "net/http"
@ -19,7 +18,6 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap" "gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers" "gitea.futuresens.co.uk/futuresens/hardlink/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/logging" "gitea.futuresens.co.uk/futuresens/hardlink/logging"
@ -27,82 +25,72 @@ import (
) )
const ( const (
buildVersion = "1.1.2" buildVersion = "1.0.30"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second
) )
func main() { func main() {
// Load config // Load config
cfg := config.ReadHardlinkConfig() config := config.ReadHardlinkConfig()
printer.Layout = readTicketLayout() printer.Layout = readTicketLayout()
printer.PrinterName = cfg.PrinterName printer.PrinterName = config.PrinterName
lockserver.Cert = cfg.Cert lockserver.Cert = config.Cert
lockserver.LockServerURL = cfg.LockserverUrl lockserver.LockServerURL = config.LockserverUrl
dispHandle := &serial.Port{}
var (
dispPort *serial.Port
disp *dispenser.Client
cardWellStatus string
)
// Setup logging and get file handle // Setup logging and get file handle
logFile, err := logging.SetupLogging(cfg.LogDir, serviceName, buildVersion) logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
if err != nil { if err != nil {
log.Printf("Failed to set up logging: %v\n", err) log.Printf("Failed to set up logging: %v\n", err)
} }
defer logFile.Close() defer logFile.Close()
// Initialize dispenser // Initialize dispenser
if !cfg.TestMode { if !config.TestMode {
dispenser.SerialPort = cfg.DispenserPort dispenser.SerialPort = config.DispenserPort
dispenser.Address = []byte(cfg.DispenserAdrr) dispenser.Address = []byte(config.DispenserAdrr)
dispHandle, err = dispenser.InitializeDispenser()
dispPort, err = dispenser.InitializeDispenser()
if err != nil { if err != nil {
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
defer dispPort.Close() defer dispHandle.Close()
// Start queued dispenser client (single goroutine owns the serial port) status, err := dispenser.CheckDispenserStatus(dispHandle)
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 { if err != nil {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr) if len(status) == 0 {
errorhandlers.FatalError(err) err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
handlers.FatalError(err)
} else {
fmt.Println(status)
fmt.Println(err.Error())
}
} }
fmt.Println(cardWellStatus) log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
} }
// Test lock-server connection // Test lock-server connection
switch strings.ToLower(cfg.LockType) { switch strings.ToLower(config.LockType) {
case lockserver.TLJ: case lockserver.TLJ:
// TLJ uses HTTP - skip TCP probe here (as you did before)
default: default:
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
log.Errorf(err.Error()) log.Errorf(err.Error())
} else { } else {
fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverUrl) fmt.Printf("Connected to the lock server successfuly at %s\n", config.LockserverUrl)
log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverUrl) log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl)
lockConn.Close() lockConn.Close()
} }
} }
database, err := bootstrap.OpenDB(&cfg) database, err := bootstrap.OpenDB(&config)
if err != nil { if err != nil {
log.Warnf("DB init failed: %v", err) log.Warnf("DB init failed: %v", err)
} }
defer database.Close() defer database.Close()
if cfg.IsPayment { if config.IsPayment {
fmt.Println("Payment processing is enabled") fmt.Println("Payment processing is enabled")
log.Info("Payment processing is enabled") log.Info("Payment processing is enabled")
startChipDnaClient() startChipDnaClient()
@ -112,32 +100,16 @@ func main() {
} }
// Create App and wire routes // Create App and wire routes
// NOTE: change handlers.NewApp signature to accept *dispenser.Client instead of *serial.Port app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, database, config.IsPayment)
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() mux := http.NewServeMux()
app.RegisterRoutes(mux) app.RegisterRoutes(mux)
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", config.Port)
log.Infof("Starting HTTP server on http://localhost%s", addr) log.Infof("Starting HTTP server on http://localhost%s", addr)
fmt.Printf("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 { if err := http.ListenAndServe(addr, mux); err != nil {
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
} }
@ -148,12 +120,12 @@ func readTicketLayout() printer.LayoutOptions {
// 1) Read the file // 1) Read the file
data, err := os.ReadFile(layoutName) data, err := os.ReadFile(layoutName)
if err != nil { if err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err)) handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
} }
// 2) Unmarshal into your struct // 2) Unmarshal into your struct
if err := xml.Unmarshal(data, &layout); err != nil { if err := xml.Unmarshal(data, &layout); err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err)) handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
} }
return layout return layout
@ -172,7 +144,7 @@ func startChipDnaClient() {
cmd, err := startClient() cmd, err := startClient()
if err != nil { if err != nil {
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
// Restart loop // Restart loop

View File

@ -2,22 +2,6 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.1.2 - 02 February 2026
added logging for unknown dispenser status positions
#### 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
`/takepayment` to request taking payment
added preauth releaser functionality to release preauthorization payments after a defined time period
added db connection check before adding a transaction to the database
and reconnection functionality if the connection to the database is lost
added `/dispenserstatus` endpoint
key card always stays at encoder position
#### 1.0.30 - 09 January 2026 #### 1.0.30 - 09 January 2026
improved logging for preauth releaser improved logging for preauth releaser