initial commit
This commit is contained in:
commit
1f383eafa1
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@ -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
|
153
README.md
Normal file
153
README.md
Normal file
@ -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 <branch>
|
||||||
|
```
|
||||||
|
2. **Tag and build**:
|
||||||
|
```bash
|
||||||
|
./tagbuild.sh
|
||||||
|
```
|
||||||
|
This script:
|
||||||
|
- Builds the latest `hardlink.exe` binary
|
||||||
|
- Tags the repository with the new version
|
||||||
|
- Copies `hardlink_<tag>.exe` to `salt/hardlink/files`
|
||||||
|
3. **Commit the new binary** in Salt:
|
||||||
|
```bash
|
||||||
|
cd salt
|
||||||
|
git add hardlink/files/hardlink_<tag>.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
|
||||||
|
<LayoutOptions>
|
||||||
|
<LogoPath>C:/Logo/logo.png</LogoPath>
|
||||||
|
<TopMostText>Welcome to The Bike and Boot</TopMostText>
|
||||||
|
<BeforeRoomNumberText>Your room number is</BeforeRoomNumberText>
|
||||||
|
<BeforeDirectionsText></BeforeDirectionsText>
|
||||||
|
<HotelSpecificDetails>This is the hotel details section</HotelSpecificDetails>
|
||||||
|
<CheckOutTimeText>Check Out Time</CheckOutTimeText>
|
||||||
|
<RoomMapFolderPath>C:/Logo/Roommaps</RoomMapFolderPath>
|
||||||
|
</LayoutOptions>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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:<port>` (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": <status>, "Message": "<error>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Print Room Ticket
|
||||||
|
|
||||||
|
- **Endpoint**: `POST /printroomticket`
|
||||||
|
- **Headers**: `Content-Type: application/xml`
|
||||||
|
- **Body**:
|
||||||
|
```xml
|
||||||
|
<roomdetails>
|
||||||
|
<customername>John Doe</customername>
|
||||||
|
<checkoutdatetime>16/05/2025 11:00 am</checkoutdatetime>
|
||||||
|
<roomno>103</roomno>
|
||||||
|
<roommap>map.png</roommap>
|
||||||
|
<roomdirections>Follow corridor...</roomdirections>
|
||||||
|
</roomdetails>
|
||||||
|
```
|
||||||
|
- **Responses**:
|
||||||
|
- `200 OK`
|
||||||
|
```json
|
||||||
|
{ "Code": 200, "Message": "Print job sent successfully" }
|
||||||
|
```
|
||||||
|
- `4XX` / `5XX`
|
||||||
|
```json
|
||||||
|
{ "Code": <status>, "Message": "<error>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `<logdir>/hardlink.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
MIT © FutureSens Systems
|
270
dispenser/dispenser.go
Normal file
270
dispenser/dispenser.go
Normal file
@ -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
|
||||||
|
}
|
15
go.mod
Normal file
15
go.mod
Normal file
@ -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
|
34
go.sum
Normal file
34
go.sum
Normal file
@ -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=
|
98
lockserver/lockserver.go
Normal file
98
lockserver/lockserver.go
Normal file
@ -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
|
||||||
|
}
|
378
main.go
Normal file
378
main.go
Normal file
@ -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
|
||||||
|
}
|
343
printer/printer.go
Normal file
343
printer/printer.go
Normal file
@ -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
|
||||||
|
}
|
4
release notes.md
Normal file
4
release notes.md
Normal file
@ -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.
|
Loading…
x
Reference in New Issue
Block a user