hardlink/main.go

383 lines
11 KiB
Go

package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/tarm/serial"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/logging"
)
const (
buildVersion = "0.9.1"
serviceName = "hardlink"
customLayout = "2006-01-02 15:04:05 -0700"
)
// configRec holds values from config.yml.
type configRec struct {
Port int `yaml:"port"`
LockserverUrl string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"`
DispenserPort string `yaml:"dispensPort"`
DispenserAdrr string `yaml:"dispensAddr"`
PrinterName string `yaml:"printerName"`
LogDir string `yaml:"logdir"`
}
// DoorCardRequest is the JSON payload for /issue-door-card.
type DoorCardRequest struct {
RoomField string `json:"roomField"`
CheckinTime string `json:"checkinTime"`
CheckoutTime string `json:"checkoutTime"`
FollowStr string `json:"followStr"`
}
// App holds shared resources.
type App struct {
dispPort *serial.Port
lockConn net.Conn
config configRec
lockserver lockserver.LockServer
}
func main() {
// Load config
config := readConfig()
printer.Layout = readTicketLayout()
printer.PrinterName = config.PrinterName
// Setup logging and get file handle
logFile, err := setupLogging(config.LogDir)
if err != nil {
log.Printf("Failed to set up logging: %v\n", err)
}
defer logFile.Close()
// Initialize dispenser
dispenser.SerialPort = config.DispenserPort
dispenser.Address = []byte(config.DispenserAdrr)
dispHandle, err := dispenser.InitializeDispenser()
if err != nil {
fatalError(err)
}
defer dispHandle.Close()
status, err := dispenser.CheckDispenserStatus(dispHandle)
if err != nil {
if len(status) == 0 {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
fatalError(err)
} else {
fmt.Println(status)
fmt.Println(err.Error())
}
}
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
// Initialize lock-server connection once
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
if err != nil {
fatalError(err)
}
defer lockConn.Close()
log.Infof("Connected to lock server at %s", config.LockserverUrl)
// Create App and wire routes
app := &App{
dispPort: dispHandle,
lockConn: lockConn,
config: config,
}
switch config.LockType {
case lockserver.AssaAbloy:
app.lockserver = &lockserver.AssaLockServer{}
case lockserver.Omnitec:
app.lockserver = &lockserver.OmniLockServer{}
default:
err = fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", config.LockType)
fatalError(err)
}
mux := http.NewServeMux()
setUpRoutes(app, mux)
addr := fmt.Sprintf(":%d", config.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 {
fatalError(err)
}
}
func setUpRoutes(app *App, mux *http.ServeMux) {
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
mux.HandleFunc("/printroomticket", app.printRoomTicket)
}
func fatalError(err error) {
fmt.Println(err.Error())
log.Errorf(err.Error())
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(1)
}
// setupLogging ensures log directory, opens log file, and configures logrus.
// Returns the *os.File so caller can defer its Close().
func setupLogging(logDir string) (*os.File, error) {
fileName := logDir + serviceName + ".log"
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
log.SetOutput(f)
log.SetFormatter(&log.JSONFormatter{
TimestampFormat: time.RFC3339,
})
log.SetLevel(log.InfoLevel)
log.WithFields(log.Fields{
"buildVersion": buildVersion,
}).Info("Logging initialized")
return f, nil
}
// 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 (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("issueDoorCard")
var (
doorReq DoorCardRequest
theResponse cmstypes.StatusRec
)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("issueDoorCard called")
if r.Method != http.MethodPost {
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")
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())
return
}
// parse times
checkIn, err := time.Parse(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())
return
}
checkOut, err := time.Parse(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())
return
}
// build command
app.lockserver.BuildCommand(app.config.EncoderAddress, doorReq.RoomField, checkIn, checkOut)
// dispenser sequence
if status, err := dispenser.CheckDispenserStatus(app.dispPort); err != nil {
if status != "" {
logging.Error(serviceName, status, "Dispenser error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser error: "+err.Error())
} else {
logging.Error(serviceName, err.Error(), "Dispenser error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, err.Error()+"; check card stock")
}
return
} else {
log.Info(status)
}
if status, err := dispenser.CardToEncoderPosition(app.dispPort); err != nil {
if status != "" {
logging.Error(serviceName, status, "Dispenser error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser move error: "+err.Error())
} else {
logging.Error(serviceName, err.Error(), "Dispenser move error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser move error: "+err.Error()+"; check card stock")
}
return
} else {
log.Info(status)
}
// lock server sequence
err = app.lockserver.LockSequence(app.lockConn)
if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
writeError(w, http.StatusBadGateway, err.Error())
dispenser.CardOutOfMouth(app.dispPort)
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)
}
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)
}
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("printRoomTicket")
log.Println("printRoomTicket called")
var roomDetails printer.RoomDetailsRec
// Allow CORS preflight if needed
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
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")
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())
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())
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())
return
}
// Success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(cmstypes.StatusRec{
Code: http.StatusOK,
Message: "Print job sent successfully",
})
}
// readConfig reads config.yml and applies defaults.
func readConfig() configRec {
var cfg configRec
const configName = "config.yml"
defaultPort := 9091
sep := string(os.PathSeparator)
data, err := os.ReadFile(configName)
if err != nil {
log.Warnf("ReadConfig %s: %v", configName, err)
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Warnf("Unmarshal config: %v", err)
}
if cfg.Port == 0 {
cfg.Port = defaultPort
}
if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName)
fatalError(err)
}
cfg.LockType = strings.ToLower(cfg.LockType)
if cfg.LogDir == "" {
cfg.LogDir = "./logs" + sep
} else if !strings.HasSuffix(cfg.LogDir, sep) {
cfg.LogDir += sep
}
return cfg
}
func readTicketLayout() printer.LayoutOptions {
const layoutName = "TicketLayout.xml"
var layout printer.LayoutOptions
// 1) Read the file
data, err := os.ReadFile(layoutName)
if err != nil {
fatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
}
// 2) Unmarshal into your struct
if err := xml.Unmarshal(data, &layout); err != nil {
fatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
}
return layout
}