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