commit 1f383eafa15ea155c813baa31e9079d15e992cc8 Author: yurii Date: Thu May 22 12:08:55 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfabbf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +*.sh +*.txt +*.pdf +*.pid +*.log +*.xml +logs/ +pdf/ +config.yml +checkintest.yml +buildlinux.sh +buildwindows.sh +css/buttons copy.css +scss/buttons.css +scss/buttons.css.map +tagbuild.sh +VERSION +Checkin.code-workspace +.DS_Store + +# ---> Go +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test +.vscode/ + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1cee24 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Access Point + +This service handles door card issuance and room ticket printing for hotels. It interacts with the card dispenser over serial, the Assa Abloy lock server, and prints receipts. + +## Deployment + +We use Salt for deployment. To build and deploy a new version: + +1. **Push your branch** with a meaningful commit message: + ```bash + git push origin + ``` +2. **Tag and build**: + ```bash + ./tagbuild.sh + ``` + This script: + - Builds the latest `hardlink.exe` binary + - Tags the repository with the new version + - Copies `hardlink_.exe` to `salt/hardlink/files` +3. **Commit the new binary** in Salt: + ```bash + cd salt + git add hardlink/files/hardlink_.exe + git commit -m "added hardlink" + git push + ``` +4. **Update pillar** (`pillar/hardlink/init.sls`) to point to the new version. +5. **Apply the state** on your hosts: + ```bash + salt '*' state.apply hardlink + ``` + +## Configuration + +Place `config.yml` alongside `hardlink.exe`: + +```yaml +port: 9091 +lockservUrl: "http://192.168.4.109:4000" +encoderAddr: "1" +dispensPort: "COM9" +dispensAddr: "15" +printerName: "EPSON TM-T82II Receipt" +logdir: "./logs" +``` + +Provide `TicketLayout.xml` for print layout: + +```xml + + C:/Logo/logo.png + Welcome to The Bike and Boot + Your room number is + + This is the hotel details section + Check Out Time + C:/Logo/Roommaps + +``` + +## Build & Run + +```bash +# Install dependencies +go mod tidy + +# Build the executable +go build -o hardlink.exe + +# Run +./hardlink.exe +``` + +The HTTP server listens on `http://localhost:` (default `9091`). + +## API Endpoints + +### 1. Issue Door Card + +- **Endpoint**: `POST /issuedoorcard` +- **Headers**: `Content-Type: application/json` +- **Body**: + ```json + { + "roomField": "101", + "checkinTime": "2025-04-08 11:16:05 +0100", + "checkoutTime": "2025-04-08 12:00:00 +0100", + "followStr": "0" + } + ``` +- **Responses**: + - `200 OK` + ```json + { "Code": 200, "Message": "Card issued successfully" } + ``` + - `4XX` / `5XX` + ```json + { "Code": , "Message": "" } + ``` + +### 2. Print Room Ticket + +- **Endpoint**: `POST /printroomticket` +- **Headers**: `Content-Type: application/xml` +- **Body**: + ```xml + + John Doe + 16/05/2025 11:00 am + 103 + map.png + Follow corridor... + + ``` +- **Responses**: + - `200 OK` + ```json + { "Code": 200, "Message": "Print job sent successfully" } + ``` + - `4XX` / `5XX` + ```json + { "Code": , "Message": "" } + ``` + +## Packages + +### `main` +- Entry point: reads config, sets up logging, initializes dispenser and lock server, loads print layout, and starts HTTP server. + +### `dispenser` +- Communicates with the card dispenser via serial port. +- Commands: check status, move card, eject card. + +### `lockserver` +- Manages TCP connection to the Assa Abloy lock server. +- Sends heartbeat and encoding commands. + +### `printer` +- Loads `TicketLayout.xml` into `LayoutOptions`. +- Provides `printLogo`, `printMap`, `BuildRoomTicket`, `SendToPrinter`. +- Converts images to ESC/POS raster commands with dithering and transparency support. + +### `cmstypes` +- Defines JSON and XML payload structures for door cards and room details. + +## Logging + +Logs are written in JSON format to `/hardlink.log`. + +--- + +MIT © FutureSens Systems diff --git a/dispenser/dispenser.go b/dispenser/dispenser.go new file mode 100644 index 0000000..c70d162 --- /dev/null +++ b/dispenser/dispenser.go @@ -0,0 +1,270 @@ +package dispenser + +import ( + // "encoding/hex" + "fmt" + // "log" + "time" + + log "github.com/sirupsen/logrus" + "github.com/tarm/serial" +) + +// Control characters. +const ( + STX = 0x02 // Start of Text + ETX = 0x03 // End of Text + ACK = 0x06 // Positive response + NAK = 0x15 // Negative response + ENQ = 0x05 // Enquiry from host + 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"` +// } +// ) + +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 + + 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 + } + + statusPos1 = map[byte]string{ + 0x38: "Dispensing card", + 0x34: "Capturing card", + 0x32: "Dispense card error", + 0x31: "Capture card error", + 0x30: "Normal", + } + + statusPos2 = map[byte]string{ + 0x38: "No captured card", + 0x34: "Card overlapped", + 0x32: "Card jammed", + 0x31: "Card pre-empty", + 0x30: "Normal", + } + + statusPos3 = map[byte]string{ + 0x38: "Card empty", + 0x34: "Card ready position", + 0x33: "Card at encoder position", + 0x32: "Card at hold card position", + 0x31: "Card out of card mouth position", + 0x30: "Normal", + } +) + +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. + posStatus := []struct { + pos int + value byte + mapper map[byte]string + }{ + {pos: 1, value: statusBytes[0], mapper: statusPos0}, + {pos: 2, value: statusBytes[1], mapper: statusPos1}, + {pos: 3, value: statusBytes[2], mapper: statusPos2}, + {pos: 4, value: statusBytes[3], mapper: statusPos3}, + } + + result := "" + for _, p := range posStatus { + statusMsg, exists := p.mapper[p.value] + if !exists { + statusMsg = "Unknown status" + } + 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) + } + } +} + +// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX. +func calculateBCC(data []byte) byte { + var bcc byte + for _, b := range data { + bcc ^= b + } + return bcc +} + +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, command...) + packet = append(packet, ETX) + bcc := calculateBCC(packet) + packet = append(packet, bcc) + 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 sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) { + n, 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 + + buf := make([]byte, 128) + 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 +} + +func InitializeDispenser() (*serial.Port, error) { + const funcName = "initializeDispenser" + serialConfig := &serial.Config{ + Name: SerialPort, + Baud: baudRate, + ReadTimeout: time.Second * 2, + } + port, err := serial.OpenPort(serialConfig) + if err != nil { + return nil, fmt.Errorf("error opening dispenser COM port: %w", err) + } + return port, nil +} + + +// if dispenser is not responding, I should repeat the command +func CheckDispenserStatus(port *serial.Port) (string, 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) + } + if len(statusResp) == 0 { + return "", fmt.Errorf("no response from dispenser") + } + status, err := checkStatus(statusResp) + if err != nil { + return status, 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) + } + if len(statusResp) == 0 { + return "", fmt.Errorf("no response from dispenser") + } + status, err = checkStatus(statusResp) + if err != nil { + return status, err + } + result += status + return result, nil +} + +func CardToEncoderPosition(port *serial.Port) (string, error) { + const funcName = "cartToEncoderPosition" + 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) + } + _, err = checkStatus(statusResp) + if 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) + } + + //Check card position status + status, err := CheckDispenserStatus(port) + if err != nil { + return "", err + } + return status, nil +} + +func CardOutOfMouth(port *serial.Port) (string, error) { + const funcName = "CardOutOfMouth" + 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) + } + _, err = checkStatus(statusResp) + if 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) + } + + //Check card position status + status, err := CheckDispenserStatus(port) + if err != nil { + return "", err + } + return status, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..663a327 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module gitea.futuresens.co.uk/futuresens/hardlink + +go 1.23.2 + +require ( + gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171 + gitea.futuresens.co.uk/futuresens/logging v1.0.9 + github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb + github.com/sirupsen/logrus v1.9.3 + github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 + golang.org/x/image v0.27.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/sys v0.32.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..44d8fec --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171 h1:/WM3mG5i4VYspeLaGFwjuvQJHM/Pks/dN3RjhrmYaN0= +gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes= +gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo= +gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM= +gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI= +gitea.futuresens.co.uk/futuresens/logging v1.0.9/go.mod h1:pepS4+sreKTXJUp1Dq2RunpvQ0oY3vU2AuYjMTZzVQo= +github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb h1:OzF7h5OJLiB2QvpxfFdUFdSedYYsEKAXnE8BwsWQPmY= +github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb/go.mod h1:aeB9oSJ1VNJXxBkCz6Krw3aW8lPx6rkWnW/hXcoujR4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lockserver/lockserver.go b/lockserver/lockserver.go new file mode 100644 index 0000000..4c2d83f --- /dev/null +++ b/lockserver/lockserver.go @@ -0,0 +1,98 @@ +package lockserver + +import ( + "bufio" + "fmt" + "net" + "net/url" + + // "os" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +var ( + LockserverUrl string +) + +func InitializeServerConnection() (net.Conn, error) { + const funcName = "InitializeServerConnection" + // Parse the URL to extract host and port + parsedUrl, err := url.Parse(LockserverUrl) + if err != nil { + return nil, fmt.Errorf("[%s] failed to parse LockserverUrl: %v", funcName, err) + } + + // Remove any leading/trailing slashes just in case + address := strings.Trim(parsedUrl.Host, "/") + + // Establish a TCP connection to the Visionline server + conn, err := net.Dial("tcp", address) + if err != nil { + return nil, fmt.Errorf("failed to connect to Visionline server: %v", err) + } + return conn, nil +} + +func SendHeartbeatToServer(conn net.Conn) (string, error) { + const funcName = "SendHeartbeatToServer" + // Write the check command to the server + heartbeat := "CCC;EAHEARTBEAT;AM1;\r\n" + log.Printf("Sending headrbeat: %q", heartbeat) + _, err := conn.Write([]byte(heartbeat)) + if err != nil { + return "", fmt.Errorf("failed to send Heartbeat command: %v", err) + } + + // set a read timeout to avoid indefinite blocking + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + + // Read the response from the server. Visionline returns the response as ASCII text terminated by CRLF. + reader := bufio.NewReader(conn) + response, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("error reading response: %v", err) + } + + substr := "RC" + response = strings.ReplaceAll(response, "\r\n", "") // Remove CRLF from the response + num := strings.Index(response, substr) // Find the index of the response code + responseCode := response[num+2 : len(response)-1] // Extract the result code from the response + if responseCode != "0" { + return "", fmt.Errorf("negative Heartbeat response code: %s", responseCode) + } + + return "Success: " + response, nil +} + +func RequestEncoding(conn net.Conn, command string) (string, error) { + const funcName = "RequestEncoding" + // Write the command to the connection + log.Printf("Sending Encoding request: %q", command) + _, err := conn.Write([]byte(command)) + if err != nil { + return "", fmt.Errorf("failed to send Encoding request: %v", err) + } + + // Optional: set a read timeout to avoid indefinite blocking + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + + // Read the response from the server. Visionline returns the response as ASCII text terminated by CRLF. + reader := bufio.NewReader(conn) + response, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("error reading response: %v", err) + } + + substr := "RC" + response = strings.ReplaceAll(response, "\r\n", "") // Remove CRLF from the response + num := strings.Index(response, substr) // Find the index of the response code + responseCode := response[num+2 : len(response)-1] // Extract the result code from the response + if responseCode != "0" { + return "", fmt.Errorf("negative lock server response code: %s", responseCode) + } + + return "Success: " + response, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..94794e5 --- /dev/null +++ b/main.go @@ -0,0 +1,378 @@ +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.0" + serviceName = "accesspoint" + 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"` + 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 +} + +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 + lockserver.LockserverUrl = config.LockserverUrl + lockConn, err := lockserver.InitializeServerConnection() + 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, + } + + 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 + ) + + log.Println("issueDoorCard called") + 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 + } + + 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 + ci := checkIn.Format("200601021504") + co := checkOut.Format("200601021504") + cmd := fmt.Sprintf("CCA;EA%s;GR%s;CO%s;CI%s;AM1;\r\n", + app.config.EncoderAddress, doorReq.RoomField, co, ci, + ) + + // 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 + resp, err := lockserver.SendHeartbeatToServer(app.lockConn) + if err != nil { + logging.Error(serviceName, err.Error(), "Lock server heartbeat error", string(op), "", "", 0) + writeError(w, http.StatusBadGateway, "Lock server heartbeat failed: "+err.Error()) + dispenser.CardOutOfMouth(app.dispPort) + return + } + log.Infof("Heartbeat response: %s", resp) + resp, err = lockserver.RequestEncoding(app.lockConn, cmd) + if err != nil { + logging.Error(serviceName, err.Error(), "Lock server encoding error", string(op), "", "", 0) + writeError(w, http.StatusBadGateway, "Lock server encoding failed: "+err.Error()) + dispenser.CardOutOfMouth(app.dispPort) + return + } + log.Infof("Lock server response: %s", resp) + + // 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.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 +} diff --git a/printer/printer.go b/printer/printer.go new file mode 100644 index 0000000..3ad6763 --- /dev/null +++ b/printer/printer.go @@ -0,0 +1,343 @@ +package printer + +import ( + "bytes" + "encoding/xml" + "fmt" + "image" + + "image/color" + "image/draw" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + + "github.com/alexbrainman/printer" + log "github.com/sirupsen/logrus" + imagedraw "golang.org/x/image/draw" +) + +type ( + RoomDetailsRec struct { + XMLName xml.Name `xml:"roomdetails"` + Name string `xml:"customername"` + Checkout string `xml:"checkoutdatetime"` + RoomID string `xml:"roomno"` + Map string `xml:"roommap"` + Directions string `xml:"roomdirections"` + } + + LayoutOptions struct { + XMLName xml.Name `xml:"LayoutOptions"` + LogoPath string `xml:"LogoPath"` + TopMostText string `xml:"TopMostText"` + BeforeRoomNumberText string `xml:"BeforeRoomNumberText"` + BeforeDirectionsText string `xml:"BeforeDirectionsText"` + HotelSpecificDetails string `xml:"HotelSpecificDetails"` + CheckOutTimeText string `xml:"CheckOutTimeText"` + RoomMapFolderPath string `xml:"RoomMapFolderPath"` + } + + RoomTicket struct { + Logo string + TopMostText string + Name string + BeforeRoomNumberText string + RoomID string + BeforeDirectionsText string + Directions string + HotelSpecificDetails string + Map string + CheckOutTimeText string + Checkout string + } +) + +var ( + Layout LayoutOptions + PrinterName string +) + +func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) { + var buf bytes.Buffer + + // shortcuts + write := func(b []byte) { buf.Write(b) } + writeStr := func(s string) { buf.WriteString(s) } + + const ( + ESC = 0x1B + GS = 0x1D + CENTER = 0x01 + BOLD_ON = 0x01 + BOLD_OFF = 0x00 + LARGE_FONT = 0x11 + WIDER_FONT = 0x10 + NORMAL_FONT = 0x00 + ) + + // 0) Hotel logo at top + logoBytes, err := printLogo(Layout.LogoPath) + if err != nil { + log.Printf("map render: %s", err.Error()) + } + + write(logoBytes) + writeStr("\n\n") + + // 1) TopMostText + write([]byte{GS, '!', NORMAL_FONT}) + write([]byte{ESC, 'a', CENTER}) + writeStr(Layout.TopMostText + "\n\n") + + // 2) Guest name + write([]byte{ESC, 'a', CENTER}) + writeStr(details.Name + "\n\n") + + // 3) "Your room number is" + write([]byte{ESC, 'a', CENTER}) + writeStr(Layout.BeforeRoomNumberText + "\n\n") + + // 4) RoomID in bold + write([]byte{ESC, 'a', CENTER}) + write([]byte{GS, '!', WIDER_FONT}) + writeStr(details.RoomID + "\n") + write([]byte{GS, '!', NORMAL_FONT}) + writeStr("\n") + + // 5) Directions label + write([]byte{ESC, 'a', CENTER}) + writeStr(Layout.BeforeDirectionsText + "\n") + + // 6) Directions text + write([]byte{ESC, 'a', CENTER}) + writeStr(details.Directions + "\n\n") + + // 7) Hotel-specific details + write([]byte{ESC, 'a', CENTER}) + writeStr(Layout.HotelSpecificDetails + "\n\n") + + // 8) Room map image + mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map) + mapBytes, err := printMap(mapPath) + if err != nil { + log.Printf("map render: %s", err.Error()) + } + write(mapBytes) + writeStr("\n\n") + + // 9) CheckOutTimeText label in bold + write([]byte{ESC, 'a', CENTER}) + write([]byte{GS, '!', WIDER_FONT}) + writeStr(Layout.CheckOutTimeText + "\n") + + // 10) Actual Checkout value in bold + write([]byte{ESC, 'a', CENTER}) + writeStr(details.Checkout + "\n\n\n") + write([]byte{GS, '!', NORMAL_FONT}) + + // 11) Final feed + cut + write([]byte{ESC, 'd', 3}) + write([]byte{GS, 'V', 1}) + + return buf.Bytes(), nil +} + +func printLogo(path string) ([]byte, error) { + const maxLogoWidth = 384 + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open logo %q: %w", path, err) + } + defer f.Close() + srcImg, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode logo %q: %w", path, err) + } + + // 2) Composite over white + bounds := srcImg.Bounds() + whiteBg := image.NewRGBA(bounds) + draw.Draw(whiteBg, bounds, &image.Uniform{C: color.White}, image.Point{}, draw.Src) + draw.Draw(whiteBg, bounds, srcImg, bounds.Min, draw.Over) + + // 3) Scale if too wide + w, h := bounds.Dx(), bounds.Dy() + if w > maxLogoWidth { + ratio := float64(maxLogoWidth) / float64(w) + newW := maxLogoWidth + newH := int(float64(h) * ratio) + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + imagedraw.NearestNeighbor.Scale(dst, dst.Bounds(), whiteBg, bounds, imagedraw.Over, nil) + whiteBg = dst + w, h = newW, newH + } + + // 4) Dither (Floyd–Steinberg) + gray := make([][]float32, h) + for y := 0; y < h; y++ { + gray[y] = make([]float32, w) + for x := 0; x < w; x++ { + r, g, b, _ := whiteBg.At(x, y).RGBA() + l := 0.299*float32(r)/65535 + + 0.587*float32(g)/65535 + + 0.114*float32(b)/65535 + gray[y][x] = l + } + } + + bin := make([][]bool, h) + for y := 0; y < h; y++ { + bin[y] = make([]bool, w) + for x := 0; x < w; x++ { + old := gray[y][x] + newV := float32(0.0) + if old > 0.5 { + newV = 1.0 + } + bin[y][x] = (newV == 0.0) + err := old - newV + if x+1 < w { + gray[y][x+1] += err * 7 / 16 + } + if y+1 < h { + if x > 0 { + gray[y+1][x-1] += err * 3 / 16 + } + gray[y+1][x] += err * 5 / 16 + if x+1 < w { + gray[y+1][x+1] += err * 1 / 16 + } + } + } + } + + // 5) Pack bits + widthBytes := (w + 7) / 8 + var bitmap []byte + for y := 0; y < h; y++ { + for xb := 0; xb < widthBytes; xb++ { + var b byte + for bit := 0; bit < 8; bit++ { + px := xb*8 + bit + if px < w && bin[y][px] { + b |= 1 << (7 - bit) + } + } + bitmap = append(bitmap, b) + } + } + + // 6) GS v 0 header + xL := byte(widthBytes & 0xFF) + xH := byte(widthBytes >> 8) + yL := byte(h & 0xFF) + yH := byte(h >> 8) + buf := bytes.NewBuffer(nil) + buf.Write([]byte{0x1D, 0x76, 0x30, 0x00, xL, xH, yL, yH}) + buf.Write(bitmap) + + return buf.Bytes(), nil +} + +func printMap(path string) ([]byte, error) { + // 1) Open and decode the source image + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open image %q: %w", path, err) + } + defer f.Close() + + srcImg, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode image %q: %w", path, err) + } + + // 2) Composite over white background to handle transparency + bounds := srcImg.Bounds() + whiteBg := image.NewRGBA(bounds) + draw.Draw(whiteBg, bounds, &image.Uniform{C: color.White}, image.Point{}, draw.Src) + draw.Draw(whiteBg, bounds, srcImg, bounds.Min, draw.Over) + + // 3) Build monochrome bitmap + w, h := bounds.Dx(), bounds.Dy() + widthBytes := (w + 7) / 8 + var bitmap []byte + for y := 0; y < h; y++ { + for xb := 0; xb < widthBytes; xb++ { + var b byte + for bit := 0; bit < 8; bit++ { + x := xb*8 + bit + if x >= w { + continue + } + gray := color.GrayModel.Convert(whiteBg.At(bounds.Min.X+x, bounds.Min.Y+y)).(color.Gray) + if gray.Y < 128 { + b |= 1 << (7 - bit) + } + } + bitmap = append(bitmap, b) + } + } + + // 4) Prefix with ESC/POS raster image header (GS v 0) + xL := byte(widthBytes & 0xFF) + xH := byte((widthBytes >> 8) & 0xFF) + yL := byte(h & 0xFF) + yH := byte((h >> 8) & 0xFF) + + cmd := bytes.NewBuffer(nil) + cmd.Write([]byte{0x1D, 0x76, 0x30, 0x00, xL, xH, yL, yH}) + cmd.Write(bitmap) + + return cmd.Bytes(), nil +} + +func SendToPrinter(data []byte) error { + // Open the printer by its Windows name + h, err := printer.Open(PrinterName) + if err != nil { + return fmt.Errorf("printer.Open(%q): %w", PrinterName, err) + } + defer h.Close() + + // Start a new RAW document + if err := h.StartDocument("ReceiptPrintJob", "RAW"); err != nil { + return fmt.Errorf("StartDocument RAW: %w", err) + } + defer h.EndDocument() + + // Start the page + if err := h.StartPage(); err != nil { + return fmt.Errorf("StartPage: %w", err) + } + + // Write the main receipt data + n, err := h.Write(data) + if err != nil { + return fmt.Errorf("Write data: %w", err) + } + if n < len(data) { + return fmt.Errorf("partial write: %d of %d bytes", n, len(data)) + } + + // Feed a few lines and issue a partial cut + // ESC d 3 (feeds 3 lines) -> 0x1B 0x64 0x03 + // GS V 66 0 (partial cut) -> 0x1D 0x56 0x42 0x00 + // feedAndCut := []byte{ + // 0x1B, 0x64, 0x03, // Feed 3 lines + // 0x1D, 0x56, 0x42, 0x00, // Partial cut + // } + // if _, err := h.Write(feedAndCut); err != nil { + // return fmt.Errorf("Write feed+cut command: %w", err) + // } + + // End the page + if err := h.EndPage(); err != nil { + return fmt.Errorf("EndPage: %w", err) + } + + return nil +} diff --git a/release notes.md b/release notes.md new file mode 100644 index 0000000..1e7eea6 --- /dev/null +++ b/release notes.md @@ -0,0 +1,4 @@ +#### 0.9.0 - 22 May 2024 +The new API has two new endpoints: +- `/issuedoorcard` - encoding the door card for the room. +- `/printroomticket` - printing the room ticket. \ No newline at end of file