Compare commits

..

2 Commits

6 changed files with 384 additions and 130 deletions

View File

@ -3,6 +3,8 @@ package dispenser
import (
// "encoding/hex"
"fmt"
"strings"
// "log"
"time"
@ -69,9 +71,7 @@ var (
}
)
func checkStatus(statusResp []byte) (string, error) {
if len(statusResp) > 3 {
statusBytes := statusResp[7:11] // Extract the relevant bytes from the response
func logStatus(statusBytes []byte) {
// For each position, get the ASCII character, hex value, and mapped meaning.
posStatus := []struct {
pos int
@ -84,29 +84,65 @@ 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;", p.value)
}
if p.value != 0x30 {
result += fmt.Sprintf("Status: %s; ", statusMsg)
}
if p.pos == 4 && p.value == 0x38 {
return result, fmt.Errorf("Card well empty")
result.WriteString(statusMsg + "; ")
}
}
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 {
if statusBytes == nil {
return false
}
switch statusBytes[3] {
case 0x33: // Card at encoder position
return true
default:
return false // Not at encoder position
}
}
func stockTake(statusBytes []byte) string {
status := ""
if statusBytes == nil {
return status
}
if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]]
}
if statusBytes[3] == 0x38 { // Card well empty
status = statusPos3[statusBytes[3]]
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
if statusBytes == nil {
return false
}
switch statusBytes[3] {
case 0x38: // Card well empty
return true
default:
return false
}
}
func checkACK(statusResp []byte) error {
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
return nil
} else if len(statusResp) > 0 && statusResp[0] == NAK {
return fmt.Errorf("negative response from dispenser")
} else {
return fmt.Errorf("unexpected response status: % X", statusResp)
}
}
@ -171,47 +207,130 @@ func InitializeDispenser() (*serial.Port, error) {
return port, nil
}
func DispenserSequence(port *serial.Port) (string, error) {
func DispenserPrepare(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
var result string
stockStatus := ""
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
if isCardWellEmpty(status) {
return stockStatus, nil
}
result += status
if isAtEncoderPosition(status) {
return stockStatus, nil
}
// Send card to encoder position
status, err = CardToEncoderPosition(port)
err = CardToEncoderPosition(port)
if err != nil {
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
result += "; " + status
return result, nil
time.Sleep(delay)
// Check dispenser status
status, err = CheckDispenserStatus(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
return stockStatus, nil
}
func DispenserStart(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
stockStatus := ""
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
if isCardWellEmpty(status) {
return stockStatus, fmt.Errorf(stockStatus)
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
// Send card to encoder position
err = CardToEncoderPosition(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
time.Sleep(delay)
// Check dispenser status
status, err = CheckDispenserStatus(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
return stockStatus, nil
}
func DispenserFinal(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
stockStatus := ""
err := CardOutOfMouth(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error sending card to out mouth position: %v", funcName, err)
}
time.Sleep(delay)
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
time.Sleep(delay)
// Send card to encoder position
err = CardToEncoderPosition(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
time.Sleep(delay)
// Check dispenser status
status, err = CheckDispenserStatus(port)
if err != nil {
return stockStatus, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
return stockStatus, nil
}
// if dispenser is not responding, I should repeat the command
func CheckDispenserStatus(port *serial.Port) (string, error) {
func CheckDispenserStatus(port *serial.Port) ([]byte, 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)
return nil, fmt.Errorf("error sending check command: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
return nil, fmt.Errorf("no response from dispenser")
}
status, err := checkStatus(statusResp)
err = checkACK(statusResp)
if err != nil {
return status, err
return nil, err
}
result += "; " + status
// Send ENQ+ADDR to prompt device to execute the command.
statusResp, err = sendAndReceive(port, enq, delay)
@ -219,17 +338,15 @@ func CheckDispenserStatus(port *serial.Port) (string, error) {
log.Errorf("error sending ENQ: %v", 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 len(statusResp) < 13 {
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
}
result += status
return result, nil
return statusResp[7:11], nil // Return status bytes
}
func CardToEncoderPosition(port *serial.Port) (string, error) {
func CardToEncoderPosition(port *serial.Port) error {
const funcName = "cartToEncoderPosition"
enq := append([]byte{ENQ}, Address...)
@ -238,30 +355,22 @@ func CardToEncoderPosition(port *serial.Port) (string, error) {
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: %v", err)
}
_, err = checkStatus(statusResp)
err = checkACK(statusResp)
if err != nil {
return "", err
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: %v", 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) {
func CardOutOfMouth(port *serial.Port) error {
const funcName = "CardOutOfMouth"
enq := append([]byte{ENQ}, Address...)
@ -270,25 +379,17 @@ func CardOutOfMouth(port *serial.Port) (string, error) {
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: %v", err)
}
_, err = checkStatus(statusResp)
err = checkACK(statusResp)
if err != nil {
return "", err
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: %v", err)
}
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
return "", err
}
return status, nil
return nil
}

View File

@ -1,13 +1,25 @@
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()
@ -40,7 +52,6 @@ func (app *App) getDB(ctx context.Context) (*sql.DB, error) {
return nil, err
}
// Optional ping (InitMSSQL already pings, but this keeps semantics explicit)
pingCtx, cancel2 := context.WithTimeout(dialCtx, 1*time.Second)
defer cancel2()
@ -52,3 +63,144 @@ func (app *App) getDB(ctx context.Context) (*sql.DB, error) {
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,6 +2,7 @@ package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"encoding/xml"
@ -15,7 +16,6 @@ import (
"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/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
@ -33,15 +33,17 @@ type App struct {
db *sql.DB
cfg *config.ConfigRec
dbMu sync.Mutex
cardWellStatus string
}
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, db *sql.DB, cfg *config.ConfigRec) *App {
func NewApp(dispPort *serial.Port, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
return &App{
isPayment: cfg.IsPayment,
dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError),
db: db,
cfg: cfg,
cardWellStatus: cardWellStatus,
}
}
@ -50,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) {
@ -137,14 +140,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save {
dbConn, err := app.getDB(r.Context())
if err != nil {
log.WithError(err).Warn("DB unavailable; preauth not stored")
} else {
if err := db.InsertPreauth(r.Context(), dbConn, result.Fields, theRequest.CheckoutDate); err != nil {
log.WithError(err).Warn("Failed to store preauth in DB")
}
}
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
@ -285,18 +281,10 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
return
}
// dispenser sequence
if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
if status != "" {
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
} else {
if app.cardWellStatus, err = dispenser.DispenserStart(app.dispPort); err != nil {
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
errorhandlers.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)
}
// build lock server command
@ -306,18 +294,16 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
err = app.lockserver.LockSequence()
if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
dispenser.DispenserFinal(app.dispPort)
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
dispenser.CardOutOfMouth(app.dispPort)
return
}
// final dispenser steps
if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
if app.cardWellStatus, err = dispenser.DispenserFinal(app.dispPort); err != nil {
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
return
} else {
log.Info(status)
}
theResponse.Code = http.StatusOK
@ -379,3 +365,12 @@ 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,
})
}

View File

@ -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)
}

16
main.go
View File

@ -18,15 +18,15 @@ 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/handlers"
"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"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
)
const (
buildVersion = "1.0.30"
buildVersion = "1.1.0"
serviceName = "hardlink"
)
@ -38,6 +38,7 @@ func main() {
lockserver.Cert = config.Cert
lockserver.LockServerURL = config.LockserverUrl
dispHandle := &serial.Port{}
cardWellStatus := ""
// Setup logging and get file handle
logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
@ -56,17 +57,12 @@ func main() {
}
defer dispHandle.Close()
status, err := dispenser.CheckDispenserStatus(dispHandle)
cardWellStatus, err = dispenser.DispenserPrepare(dispHandle)
if err != nil {
if len(status) == 0 {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
errorhandlers.FatalError(err)
} else {
fmt.Println(status)
fmt.Println(err.Error())
}
}
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
fmt.Println(cardWellStatus)
}
// Test lock-server connection
@ -101,7 +97,7 @@ func main() {
}
// Create App and wire routes
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, database, &config)
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, cardWellStatus, database, &config)
mux := http.NewServeMux()
app.RegisterRoutes(mux)

View File

@ -2,6 +2,16 @@
builtVersion is a const in main.go
#### 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