363 lines
10 KiB
Go
363 lines
10 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.2"
|
|
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
|
|
lockserver lockserver.LockServer
|
|
}
|
|
|
|
func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec) *App {
|
|
return &App{
|
|
dispPort: dispPort,
|
|
lockConn: lockConn,
|
|
lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError),
|
|
}
|
|
}
|
|
|
|
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 := newApp(dispHandle, lockConn, config)
|
|
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
|
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
|
|
}
|
|
return
|
|
} else {
|
|
log.Info(status)
|
|
}
|
|
|
|
// build lock server command
|
|
app.lockserver.BuildCommand(doorReq.RoomField, checkIn, checkOut)
|
|
|
|
// 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")
|
|
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
|
|
}
|
|
log.Println("printRoomTicket called")
|
|
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
|
|
}
|