Compare commits
6 Commits
1.0.29
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| aeea755045 | |||
| ebe50b17a9 | |||
| 4a255c06ed | |||
| 9f0a9c939f | |||
| 7f6262b470 | |||
| 895849376e |
@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
|
||||
log "github.com/sirupsen/logrus"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
@ -49,7 +49,7 @@ func ReadHardlinkConfig() ConfigRec {
|
||||
|
||||
if cfg.LockType == "" {
|
||||
err = fmt.Errorf("LockType is required in %s", configName)
|
||||
handlers.FatalError(err)
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
cfg.LockType = strings.ToLower(cfg.LockType)
|
||||
|
||||
@ -81,7 +81,7 @@ func ReadPreauthReleaserConfig() ConfigRec {
|
||||
|
||||
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
|
||||
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
|
||||
handlers.FatalError(err)
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
|
||||
if cfg.LogDir == "" {
|
||||
|
||||
1
db/db.go
1
db/db.go
@ -198,3 +198,4 @@ WHERE TxnReference = @TxnReference AND Released = 0;
|
||||
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
// "encoding/hex"
|
||||
"fmt"
|
||||
// "log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@ -20,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",
|
||||
@ -50,7 +45,6 @@ var (
|
||||
0x31: "Capture card error",
|
||||
0x30: "Normal",
|
||||
}
|
||||
|
||||
statusPos2 = map[byte]string{
|
||||
0x38: "No captured card",
|
||||
0x34: "Card overlapped",
|
||||
@ -58,7 +52,6 @@ var (
|
||||
0x31: "Card pre-empty",
|
||||
0x30: "Normal",
|
||||
}
|
||||
|
||||
statusPos3 = map[byte]string{
|
||||
0x38: "Card empty",
|
||||
0x34: "Card ready position",
|
||||
@ -69,10 +62,16 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func checkStatus(statusResp []byte) (string, error) {
|
||||
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.
|
||||
// --------------------
|
||||
// Status helpers
|
||||
// --------------------
|
||||
|
||||
func logStatus(statusBytes []byte) {
|
||||
if len(statusBytes) < 4 {
|
||||
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
|
||||
return
|
||||
}
|
||||
|
||||
posStatus := []struct {
|
||||
pos int
|
||||
value byte
|
||||
@ -84,33 +83,56 @@ func checkStatus(statusResp []byte) (string, error) {
|
||||
{pos: 4, value: statusBytes[3], mapper: statusPos3},
|
||||
}
|
||||
|
||||
result := ""
|
||||
var result strings.Builder
|
||||
for _, p := range posStatus {
|
||||
statusMsg, exists := p.mapper[p.value]
|
||||
if !exists {
|
||||
statusMsg = "Unknown status"
|
||||
statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos)
|
||||
}
|
||||
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)
|
||||
result.WriteString(statusMsg + "; ")
|
||||
}
|
||||
}
|
||||
log.Infof("Dispenser status: %s", result.String())
|
||||
}
|
||||
|
||||
// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
|
||||
func isAtEncoderPosition(statusBytes []byte) bool {
|
||||
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 {
|
||||
var bcc byte
|
||||
for _, b := range data {
|
||||
@ -121,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)
|
||||
@ -130,165 +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,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
port, err := serial.OpenPort(serialConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func DispenserSequence(port *serial.Port) (string, error) {
|
||||
const funcName = "dispenserSequence"
|
||||
var result string
|
||||
// --------------------
|
||||
// Internal (port-owner only) operations
|
||||
// --------------------
|
||||
|
||||
// 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
|
||||
// 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 "", fmt.Errorf("error sending check command: %v", err)
|
||||
return nil, fmt.Errorf("error sending check command: %w", err)
|
||||
}
|
||||
if len(statusResp) == 0 {
|
||||
return "", fmt.Errorf("no response from dispenser")
|
||||
return nil, fmt.Errorf("no response from dispenser")
|
||||
}
|
||||
status, err := checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return status, err
|
||||
if err := checkACK(statusResp); err != nil {
|
||||
return nil, 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)
|
||||
return nil, fmt.Errorf("error sending ENQ: %w", err)
|
||||
}
|
||||
if len(statusResp) == 0 {
|
||||
return "", fmt.Errorf("no response from dispenser")
|
||||
if len(statusResp) < 13 {
|
||||
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
|
||||
}
|
||||
status, err = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
result += status
|
||||
return result, nil
|
||||
return statusResp[7:11], nil
|
||||
}
|
||||
|
||||
func CardToEncoderPosition(port *serial.Port) (string, 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 = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
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)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
//Check card position status
|
||||
status, err := CheckDispenserStatus(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func CardOutOfMouth(port *serial.Port) (string, 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 = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
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)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
//Check card position status
|
||||
status, err := CheckDispenserStatus(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
341
dispenser/dispenserclient.go
Normal file
341
dispenser/dispenserclient.go
Normal file
@ -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
|
||||
}
|
||||
32
errorhandlers/errorhandlers.go
Normal file
32
errorhandlers/errorhandlers.go
Normal file
@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
206
handlers/db_helpers.go
Normal file
206
handlers/db_helpers.go
Normal file
@ -0,0 +1,206 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -2,19 +2,20 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tarm/serial"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/db"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"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/payment"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
|
||||
@ -24,19 +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 string, db *sql.DB, isPayment bool) *App {
|
||||
return &App{
|
||||
isPayment: isPayment,
|
||||
dispPort: dispPort,
|
||||
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
|
||||
func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
|
||||
app := &App{
|
||||
isPayment: cfg.IsPayment,
|
||||
disp: disp,
|
||||
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError),
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
}
|
||||
app.SetCardWellStatus(cardWellStatus)
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
@ -44,6 +52,7 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/printroomticket", app.printRoomTicket)
|
||||
mux.HandleFunc("/takepreauth", app.takePreauthorization)
|
||||
mux.HandleFunc("/takepayment", app.takePayment)
|
||||
mux.HandleFunc("/dispenserstatus", app.reportDispenserStatus)
|
||||
}
|
||||
|
||||
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
@ -131,8 +140,9 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
|
||||
if save {
|
||||
db.InsertPreauth(r.Context(), app.db, result.Fields, theRequest.CheckoutDate)
|
||||
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
|
||||
}
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
}
|
||||
|
||||
@ -241,19 +251,19 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Println("issueDoorCard called")
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@ -261,57 +271,61 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
|
||||
return
|
||||
}
|
||||
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// dispenser sequence
|
||||
if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
|
||||
if status != "" {
|
||||
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
|
||||
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
} else {
|
||||
// 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)
|
||||
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
|
||||
}
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
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
|
||||
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)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
dispenser.CardOutOfMouth(app.dispPort)
|
||||
finalize()
|
||||
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// final dispenser steps
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@ -328,32 +342,32 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
log.Println("printRoomTicket called")
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
|
||||
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data, err := printer.BuildRoomTicket(roomDetails)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
|
||||
writeError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send to the Windows Epson TM-T82II via the printer package
|
||||
if err := printer.SendToPrinter(data); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
|
||||
writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
|
||||
errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@ -365,3 +379,24 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -2,28 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
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 writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@ -32,10 +18,3 @@ 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)
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.0.1"
|
||||
buildVersion = "1.0.2"
|
||||
serviceName = "preauth-release"
|
||||
)
|
||||
|
||||
@ -33,12 +33,15 @@ func main() {
|
||||
defer database.Close()
|
||||
|
||||
if err := payment.ReleasePreauthorizations(database); err != nil {
|
||||
log.WithError(err).Fatal("Preauth release failed")
|
||||
log.Error(err)
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
log.Info("Task completed successfully")
|
||||
fmt.Println("Task completed successfully")
|
||||
}
|
||||
|
||||
log.Info("Task completed successfully")
|
||||
for i := 20; i > 0; i-- {
|
||||
fmt.Printf("\rExiting in %2d seconds...", i)
|
||||
fmt.Printf("\rExiting in %2d seconds... ", i)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
fmt.Println("\rExiting now. ")
|
||||
|
||||
@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// 1. Send ENQ
|
||||
log.Infof("Sending ENQ")
|
||||
log.Infof("LockSequence: sending ENQ")
|
||||
if _, e := conn.Write([]byte{ENQ}); e != nil {
|
||||
return fmt.Errorf("failed to send ENQ: %w", e)
|
||||
}
|
||||
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
||||
}
|
||||
|
||||
// 3. Send command frame
|
||||
log.Infof("Sending encoding command: %q", string(lock.command))
|
||||
log.Infof("LockSequence: sending encoding command: %q", string(lock.command))
|
||||
if _, e := conn.Write(lock.command); e != nil {
|
||||
return fmt.Errorf("failed to send command frame: %w", e)
|
||||
}
|
||||
|
||||
100
main.go
100
main.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -18,6 +19,7 @@ import (
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"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/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
|
||||
@ -25,72 +27,82 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.0.29"
|
||||
buildVersion = "1.1.2"
|
||||
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{}
|
||||
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 err != nil {
|
||||
handlers.FatalError(err)
|
||||
}
|
||||
defer dispHandle.Close()
|
||||
if !cfg.TestMode {
|
||||
dispenser.SerialPort = cfg.DispenserPort
|
||||
dispenser.Address = []byte(cfg.DispenserAdrr)
|
||||
|
||||
status, err := dispenser.CheckDispenserStatus(dispHandle)
|
||||
dispPort, err = dispenser.InitializeDispenser()
|
||||
if err != nil {
|
||||
if len(status) == 0 {
|
||||
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
|
||||
handlers.FatalError(err)
|
||||
} else {
|
||||
fmt.Println(status)
|
||||
fmt.Println(err.Error())
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
defer dispPort.Close()
|
||||
|
||||
// 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, cfg.DispenserAdrr)
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
|
||||
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()
|
||||
@ -100,16 +112,32 @@ func main() {
|
||||
}
|
||||
|
||||
// Create App and wire routes
|
||||
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, database, config.IsPayment)
|
||||
// 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 {
|
||||
handlers.FatalError(err)
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,12 +148,12 @@ func readTicketLayout() printer.LayoutOptions {
|
||||
// 1) Read the file
|
||||
data, err := os.ReadFile(layoutName)
|
||||
if err != nil {
|
||||
handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||||
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
// 2) Unmarshal into your struct
|
||||
if err := xml.Unmarshal(data, &layout); err != nil {
|
||||
handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||||
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
return layout
|
||||
@ -144,7 +172,7 @@ func startChipDnaClient() {
|
||||
|
||||
cmd, err := startClient()
|
||||
if err != nil {
|
||||
handlers.FatalError(err)
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
|
||||
// Restart loop
|
||||
|
||||
@ -21,7 +21,7 @@ const (
|
||||
)
|
||||
|
||||
/* ==============================
|
||||
Public Entry Point (LEGACY)
|
||||
Public Entry Point
|
||||
============================== */
|
||||
|
||||
func ReleasePreauthorizations(database *sql.DB) error {
|
||||
@ -78,6 +78,7 @@ func handlePreauthRelease(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("res=%s state=%s", info.transactionRes, info.transactionState)
|
||||
|
||||
// If already voided or declined → mark released
|
||||
if isAlreadyReleased(info) {
|
||||
@ -86,8 +87,7 @@ func handlePreauthRelease(
|
||||
|
||||
// Only void approved + uncommitted
|
||||
if !isVoidable(info) {
|
||||
log.Infof("Preauth %s not eligible for void (res=%s state=%s)",
|
||||
ref, info.transactionRes, info.transactionState)
|
||||
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,25 @@
|
||||
|
||||
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
|
||||
improved logging for preauth releaser
|
||||
|
||||
#### 1.0.29 - 08 January 2026
|
||||
added count down before exiting the preauth releaser 20 seconds
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user