Compare commits

...

23 Commits

Author SHA1 Message Date
efa415e631 increased timeout for TLJ lock server connection to 30 seconds 2025-09-04 14:54:50 +01:00
f1dc0ccce4 added functionality to commit transactions 2025-08-30 23:03:39 +01:00
1bceb55285 fixed TCP/IP connection to the lock server 2025-08-27 17:14:40 +01:00
bb8cdb1d84 fixed issue in creditcall payment processing where error description was not properly set 2025-08-26 16:13:21 +01:00
251afd6aeb TCP/IP connection to the lock server is now established before encoding the keycard and closed after the encoding is done 2025-08-21 19:26:42 +01:00
e6ff292706 added delay before checking dispenser status 2025-08-11 18:10:53 +01:00
b4d16f9021 updated Salto key encoding workflow 2025-08-11 14:57:33 +01:00
c8f6c57983 updated logging for TLJ locks 2025-08-08 12:39:16 +01:00
61b089fe55 updated workflow for TLJ locks 2025-08-08 11:34:02 +01:00
8b0e7df582 improved error handling and logging in Salto 2025-08-01 15:18:38 +01:00
25eab0dc75 added check if the room exists 2025-07-25 17:36:08 +01:00
fc91f2c0f2 updated workflow for Salto locks 2025-07-25 14:32:30 +01:00
5ce9fdcf0b added encoding keycard copy for Salto locks 2025-07-24 15:12:06 +01:00
b303721a92 added salto lock server and implemented workflow for Salto 2025-07-23 11:46:26 +01:00
dc91a9ae63 removed db connection 2025-07-04 13:04:05 +01:00
b303481b5f removed insertion of transaction into db 2025-07-04 12:46:50 +01:00
41cf71a253 made url parametrs hexadecimal encoded 2025-07-01 11:21:34 +01:00
5e99453eeb added creditcall payment method 2025-06-30 14:33:05 +01:00
b002c80b01 updated lock server 2025-06-18 11:54:29 +01:00
d9871e6a1c tidy up the app 2025-06-11 11:31:28 +01:00
140c9a44cc update reading from link start 2025-06-09 19:21:52 +01:00
b200eb3b91 date fix 2025-06-09 16:42:08 +01:00
7a9aa5ae7a link start date fix 2025-06-09 16:18:45 +01:00
14 changed files with 1405 additions and 299 deletions

1
.gitignore vendored
View File

@ -28,6 +28,7 @@ Checkin.code-workspace
_obj
_test
.vscode/
ChipDNAClient/
# Architecture specific extensions/prefixes
*.[568vq]

View File

@ -171,6 +171,26 @@ func InitializeDispenser() (*serial.Port, error) {
return port, nil
}
func DispenserSequence(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
var result string
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
result += status
// Send card to encoder position
status, err = CardToEncoderPosition(port)
if err != nil {
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
result += "; " + status
return result, nil
}
// if dispenser is not responding, I should repeat the command
func CheckDispenserStatus(port *serial.Port) (string, error) {
@ -231,6 +251,8 @@ func CardToEncoderPosition(port *serial.Port) (string, error) {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
}
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
@ -261,6 +283,8 @@ func CardOutOfMouth(port *serial.Port) (string, error) {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
}
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {

10
go.mod
View File

@ -3,13 +3,19 @@ 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/cmstypes v1.0.179
gitea.futuresens.co.uk/futuresens/logging v1.0.9
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
github.com/denisenkom/go-mssqldb v0.12.3
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
require (
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
golang.org/x/crypto v0.3.0 // indirect
golang.org/x/sys v0.32.0 // indirect
)

34
go.sum
View File

@ -1,14 +1,26 @@
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/cmstypes v1.0.179 h1:3OLzX6jJ2dwfZ9Fcijk5z6/GUdTl5FUNw3eWuRkDhZw=
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179/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/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
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/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
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=
@ -19,16 +31,34 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=

View File

@ -0,0 +1,74 @@
package lockserver
import (
"fmt"
"net"
"time"
"strings"
log "github.com/sirupsen/logrus"
)
// Build key encoding request command for the Assa Abloy lock server.
func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
ci := checkIn.Format("200601021504")
co := checkOut.Format("200601021504")
lock.command = fmt.Sprintf("CCA;EA%s;GR%s;CO%s;CI%s;AM1;\r\n", lock.encoderAddr, doorReq.RoomField, co, ci)
return nil
}
// Checks heart beat of the Assa Abloy lock server and perform key encoding
func (lock *AssaLockServer) LockSequence() error {
const funcName = "AssaLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
resp, err := sendHeartbeatToServer(conn)
if err != nil {
return fmt.Errorf("[%s] heartbeat failed: %v", funcName, err)
}
log.Infof("Heartbeat response: %s", resp)
resp, err = requestEncoding(conn, lock.command)
if err != nil {
return fmt.Errorf("[%s] request encoding failed: %v", funcName, err)
}
log.Infof("Encoding response: %s", resp)
return nil
}
func sendHeartbeatToServer(conn net.Conn) (string, error) {
const heartbeatRegister = "CCC;EAHEARTBEAT;AM1;\r\n"
raw, err := sendAndReceive(conn, []byte(heartbeatRegister))
if err != nil {
return "", fmt.Errorf("failed to send Heartbeat command: %v", err)
}
return parseAssaResponse(raw)
}
func requestEncoding(conn net.Conn, command string) (string, error) {
raw, err := sendAndReceive(conn, []byte(command))
if err != nil {
return "", fmt.Errorf("failed to send Encoding request: %v", err)
}
return parseAssaResponse(raw)
}
func parseAssaResponse(raw string) (string, error) {
clean := strings.ReplaceAll(raw, "\r\n", "")
idx := strings.Index(clean, "RC")
code := clean[idx+2 : idx+3] // Extract the response code
if code != "0" {
return "", fmt.Errorf("negative response code: %s", clean)
}
return "Success: " + clean, nil
}

View File

@ -1,222 +0,0 @@
package lockserver
import (
"bufio"
// "io"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
AssaAbloy = "assaabloy"
Omnitec = "omnitec"
)
type (
LockServer interface {
LockSequence(conn net.Conn) error
BuildCommand(encoderAddr, lockId string, checkIn, checkOut time.Time) error
}
AssaLockServer struct {
command string
}
OmniLockServer struct {
command []byte // Command to be sent to the lock server
}
)
func InitializeServerConnection(LockserverUrl string) (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
}
// sendAndReceive sends a command to the lock server and waits for a response.
func sendAndReceive(conn net.Conn, command []byte) (string, error) {
const funcName = "SendAndReceive"
// Write the command to the connection
log.Printf("Sending command: %q", command)
_, err := conn.Write(command)
if err != nil {
return "", fmt.Errorf("failed to send command: %v", err)
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
reader := bufio.NewReader(conn)
n, err := reader.Read(buf)
if err != nil {
return "", fmt.Errorf("error reading response: %v", err)
}
response := buf[:n]
return string(response), nil
}
func parseAssaResponse(raw string) (string, error) {
clean := strings.ReplaceAll(raw, "\r\n", "")
idx := strings.Index(clean, "RC")
code := clean[idx+2 : idx+3] // Extract the response code
if code != "0" {
return "", fmt.Errorf("negative response code: %s", clean)
}
return "Success: " + clean, nil
}
func parseOmniResponse(raw string) (string, error) {
clean := strings.Trim(raw, "\x02\x03")
idx := strings.Index(clean, "AS")
code := clean[idx+2 : idx+4] // Extract the response code
code = strings.ToLower(code) // Convert to lowercase for consistency
if code != "ok" {
return "", fmt.Errorf("negative response code: %s", clean)
}
return "Success: " + clean, nil
}
func sendHeartbeatToServer(conn net.Conn) (string, error) {
const heartbeatRegister = "CCC;EAHEARTBEAT;AM1;\r\n"
raw, err := sendAndReceive(conn, []byte(heartbeatRegister))
if err != nil {
return "", fmt.Errorf("failed to send Heartbeat command: %v", err)
}
return parseAssaResponse(raw)
}
func requestEncoding(conn net.Conn, command string) (string, error) {
// 1) Send and read raw response
raw, err := sendAndReceive(conn, []byte(command))
if err != nil {
return "", fmt.Errorf("failed to send Encoding request: %v", err)
}
return parseAssaResponse(raw)
}
func (lock *AssaLockServer) LockSequence(conn net.Conn) error {
resp, err := sendHeartbeatToServer(conn)
if err != nil {
return fmt.Errorf("lock server heartbeat failed: %v", err)
}
log.Infof("Heartbeat response: %s", resp)
resp, err = requestEncoding(conn, lock.command)
if err != nil {
return fmt.Errorf("lock server request encoding failed: %v", err)
}
log.Infof("Encoding response: %s", resp)
return nil
}
func (lock *AssaLockServer) BuildCommand(encoderAddr, lockId string, checkIn, checkOut time.Time) error {
ci := checkIn.Format("200601021504")
co := checkOut.Format("200601021504")
lock.command = fmt.Sprintf("CCA;EA%s;GR%s;CO%s;CI%s;AM1;\r\n", encoderAddr, lockId, co, ci)
return nil
}
func (lock *OmniLockServer) BuildCommand(encoderAddr, lockId string, checkIn, checkOut time.Time) error {
hostname, err := os.Hostname()
if err != nil {
return fmt.Errorf("could not get hostname: %v", err)
}
// Format lockId as 4-digit zero-padded string
idInt, err := strconv.Atoi(lockId)
if err != nil {
return fmt.Errorf("invalid lockId %q: %v", lockId, err)
}
formattedLockId := fmt.Sprintf("%04d", idInt)
// Format date/time parts
ga := checkIn.Format("020106") // GA = ddMMyy
gd := checkOut.Format("020106") // GD = ddMMyy
dt := checkIn.Format("15:04") // DT = HH:mm
ti := checkIn.Format("150405") // TI = HHmmss
// Construct payload
payload := fmt.Sprintf(
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
encoderAddr,
formattedLockId,
hostname,
dt,
ga,
gd,
ga,
ti,
)
// Assign to command field with STX and ETX
lock.command = append([]byte{0x02}, append([]byte(payload), 0x03)...)
return nil
}
func (lock *OmniLockServer) LockSequence(conn net.Conn) error {
const funcName = "OmniLockServer.LockSequence"
// Start the link with the lock server
raw, err := lock.linkStart(conn)
if err != nil {
return fmt.Errorf("[%s] linkStart failed: %v", funcName, err)
}
log.Infof("Link start response: %s", raw)
// Request encoding from the lock server
raw, err = lock.requestEncoding(conn)
if err != nil {
return fmt.Errorf("[%s] requestEncoding failed: %v", funcName, err)
}
log.Infof("Encoding response: %s", raw)
return nil
}
func (lock *OmniLockServer) linkStart(conn net.Conn) (string, error) {
const funcName = "OmniLockServer.linkStart"
// Send the link start command
payload := fmt.Sprintf("LS|DA%s|TI%s|", time.Now().Format("150405"), time.Now().Format("150405"))
command := append([]byte{0x02}, append([]byte(payload), 0x03)...)
raw, err := sendAndReceive(conn, command)
if err != nil {
return "", fmt.Errorf("failed to send Link Start command: %v", err)
}
return raw, nil
}
func (lock *OmniLockServer) requestEncoding(conn net.Conn) (string, error) {
const funcName = "OmniLockServer.requestEncoding"
// Send the encoding request command
raw, err := sendAndReceive(conn, lock.command)
if err != nil {
return "", fmt.Errorf("failed to send Encoding request: %v", err)
}
return parseOmniResponse(raw)
}

View File

@ -0,0 +1,115 @@
package lockserver
import (
"bufio"
// "io"
"fmt"
"net"
"net/url"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// 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"`
}
const (
AssaAbloy = "assaabloy"
Omnitec = "omnitec"
Salto = "salto"
TLJ = "tlj"
)
var (
Cert string
LockServerURL string
)
type (
LockServer interface {
BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error
LockSequence() error
}
AssaLockServer struct {
encoderAddr string
command string
}
OmniLockServer struct {
encoderAddr string // Encoder unit address
command []byte // Command to be sent to the lock server
}
SaltoLockServer struct {
encoderAddr string
command []byte
}
TLJLockServer struct {
encoderAddr string
command string
}
)
func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockServer {
switch strings.ToLower(lockType) {
case AssaAbloy:
return &AssaLockServer{encoderAddr: encoderAddr}
case Omnitec:
return &OmniLockServer{encoderAddr: encoderAddr}
case Salto:
return &SaltoLockServer{encoderAddr: encoderAddr}
case TLJ:
return &TLJLockServer{encoderAddr: encoderAddr}
default:
fatalError(fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", lockType))
return nil // This line will never be reached, but is needed to satisfy the compiler
}
}
func InitializeServerConnection(LockserverUrl string) (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 lock server: %v", err)
}
return conn, nil
}
// sendAndReceive sends a command to the lock server and waits for a response.
func sendAndReceive(conn net.Conn, command []byte) (string, error) {
log.Printf("Sending command: %q", command)
_, err := conn.Write(command)
if err != nil {
return "", fmt.Errorf("failed to send command: %v", err)
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
reader := bufio.NewReader(conn)
n, err := reader.Read(buf)
if err != nil {
return "", fmt.Errorf("error reading response: %v", err)
}
response := buf[:n]
return string(response), nil
}

View File

@ -0,0 +1,139 @@
package lockserver
import (
"bufio"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// Build key encoding request command for the Omnitec lock server.
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
const funcName = "OmniLockServer.BuildCommand"
hostname, err := os.Hostname()
if err != nil {
return fmt.Errorf("[%s] failed to get hostname: %v", funcName, err)
}
// Format lockId as 4-digit zero-padded string
idInt, err := strconv.Atoi(doorReq.RoomField)
if err != nil {
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
}
formattedLockId := fmt.Sprintf("%04d", idInt)
// Format date/time parts
dt := checkOut.Format("15:04") // DT = HH:mm
ga := checkIn.Format("060102") // GA = ddMMyy
gd := checkOut.Format("060102") // GD = ddMMyy
ti := checkIn.Format("150405") // TI = HHmmss
// Construct payload
payload := fmt.Sprintf(
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
lock.encoderAddr,
formattedLockId,
hostname,
dt,
ga,
gd,
ga,
ti,
)
// Assign to command field with STX and ETX
lock.command = append([]byte{0x02}, append([]byte(payload), 0x03)...)
return nil
}
// Starts link to the Omnitec lock server and perform key encoding
func (lock *OmniLockServer) LockSequence() error {
const funcName = "OmniLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
// Start the link with the lock server
regs, err := lock.linkStart(conn)
if err != nil {
return fmt.Errorf("[%s] linkStart failed: %v", funcName, err)
}
for _, reg := range regs {
log.Printf("Received: %q", reg)
}
// Request encoding from the lock server
raw, err := lock.requestEncoding(conn)
if err != nil {
return fmt.Errorf("[%s] request encoding failed: %v", funcName, err)
}
log.Infof("Encoding response: %s", raw)
return nil
}
func (lock *OmniLockServer) linkStart(conn net.Conn) ([]string, error) {
payload := fmt.Sprintf("LS|DA%s|TI%s|",
time.Now().Format("060102"),
time.Now().Format("150405"),
)
command := append([]byte{0x02}, append([]byte(payload), 0x03)...)
log.Printf("Sending command: %q", command)
_, err := conn.Write(command)
if err != nil {
return nil, fmt.Errorf("failed to send command: %v", err)
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
var registers []string
for {
reg, err := reader.ReadString(0x03)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
break // timeout -> assume no more registers
}
return nil, fmt.Errorf("error reading register: %v", err)
}
registers = append(registers, reg)
// stop when you see the final register type, e.g. "LA|…|<ETX>"
if strings.HasPrefix(reg, "\x02LA|") {
break
}
}
return registers, nil
}
func (lock *OmniLockServer) requestEncoding(conn net.Conn) (string, error) {
raw, err := sendAndReceive(conn, lock.command)
if err != nil {
return "", fmt.Errorf("failed to send Encoding request: %v", err)
}
return parseOmniResponse(raw)
}
func parseOmniResponse(raw string) (string, error) {
clean := strings.Trim(raw, "\x02\x03")
idx := strings.Index(clean, "AS")
code := clean[idx+2 : idx+4] // Extract the response code
code = strings.ToLower(code) // Convert to lowercase for consistency
if code != "ok" {
return "", fmt.Errorf("negative response code: %s", clean)
}
return "Success: " + clean, nil
}

View File

@ -0,0 +1,226 @@
package lockserver
import (
"bufio"
"fmt"
"net"
"time"
log "github.com/sirupsen/logrus"
)
const (
STX = 0x02 // Start of Text
ETX = 0x03 // End of Text
ENQ = 0x05 // Enquiry from host
ACK = 0x06 // Positive response
NAK = 0x15 // Negative response
separator = 0xB3 // '│' character in ASCII (179 decimal)
)
// calculateLRC computes the Longitudinal Redundancy Check over data
func calculateLRC(data []byte) byte {
var lrc byte
for _, b := range data {
lrc ^= b
}
return lrc
}
// BuildCommand assembles the SALTO frame with fields #0#10, STX/ETX and LRC
func (lock *SaltoLockServer) BuildCommand(req DoorCardRequest, checkIn, checkOut time.Time) error {
// helper: hh[mm]DDMMYY
fmtStamp := func(t time.Time) string {
return fmt.Sprintf("%02d%02d%02d%02d%02d",
t.Hour(), t.Minute(), t.Day(), int(t.Month()), t.Year()%100)
}
start := fmtStamp(checkIn)
expiry := fmtStamp(checkOut)
// command type
cmd := "CN"
if req.FollowStr == "1" {
cmd = "CC"
}
// fields 010
fields := []string{
cmd,
lock.encoderAddr,
"E",
req.RoomField,
"", // optional field 4
"", // 5
"", // 6
"", // 7
"", // 8
start,
expiry,
}
// build payload between STX and ETX
body := []byte{STX}
for _, f := range fields {
body = append(body, separator)
body = append(body, []byte(f)...)
}
body = append(body, separator)
body = append(body, ETX)
// append LRC (XOR of everything after STX through ETX)
lrc := calculateLRC(body[1:])
body = append(body, lrc)
lock.command = body
return nil
}
// readFrame consumes a full frame starting with firstByte (expected STX).
// It reads up to ETX and then attempts to read a trailing LRC (or CR). timeout controls read deadlines.
func (lock *SaltoLockServer) readFrame(conn net.Conn, reader *bufio.Reader, firstByte byte, timeout time.Duration) ([]byte, error) {
frame := []byte{firstByte}
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, e := reader.ReadByte()
if e != nil {
return frame, fmt.Errorf("error reading frame body: %w", e)
}
frame = append(frame, b)
if b == ETX {
break
}
}
// read trailing LRC (or CR) if present (non-blocking w/ timeout)
conn.SetReadDeadline(time.Now().Add(timeout))
if lrc, e := reader.ReadByte(); e == nil {
frame = append(frame, lrc)
} else {
// Not fatal: some devices might omit LRC/CR — just log
log.Warnf("readFrame: no trailing LRC/CR: %v", e)
}
return frame, nil
}
// waitForAck waits for ACK or NAK. If STX frames are encountered they are drained
// using readFrame. drainedCount (if non-nil) accumulates the number of drained frames.
func (lock *SaltoLockServer) waitForAck(conn net.Conn, reader *bufio.Reader, timeout time.Duration, drainedCount *int) error {
deadline := time.Now().Add(timeout)
for {
conn.SetReadDeadline(time.Now().Add(time.Until(deadline)))
b, e := reader.ReadByte()
if e != nil {
return fmt.Errorf("error waiting for ACK/NAK: %w", e)
}
switch b {
case ACK:
return nil
case NAK:
return fmt.Errorf("received NAK")
case STX:
// stale or queued full response: consume it and continue waiting
frame, fe := lock.readFrame(conn, reader, b, timeout)
if fe != nil {
// if we can't consume frame, consider it an error
return fmt.Errorf("failed to consume queued STX frame: %w", fe)
}
if drainedCount != nil {
*drainedCount++
log.Infof("Drained queued frame #%d (while waiting for ACK): %q", *drainedCount, string(frame))
} else {
log.Infof("Drained queued frame (while waiting for ACK): %q", string(frame))
}
// loop to keep waiting for ACK
default:
// Unexpected byte while waiting for ACK. Log and continue reading.
log.Warnf("waitForAck: unexpected byte 0x%X while waiting for ACK; ignoring", b)
// keep looping until timeout or ACK/NAK
}
}
}
// LockSequence performs the full ENQ/ACK handshake and command exchange
func (lock *SaltoLockServer) LockSequence() error {
const timeout = 10 * time.Second
var (
resp []byte
drained = 0 // count of stale frames consumed across waits
)
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
reader := bufio.NewReader(conn)
// 1. Send ENQ
log.Infof("Sending ENQ")
if _, e := conn.Write([]byte{ENQ}); e != nil {
return fmt.Errorf("failed to send ENQ: %w", e)
}
// 2. Expect ACK (but drain any queued STX frames first)
if e := lock.waitForAck(conn, reader, timeout, &drained); e != nil {
return fmt.Errorf("error awaiting ACK to ENQ: %w", e)
}
// 3. Send command frame
log.Infof("Sending encoding command: %q", string(lock.command))
if _, e := conn.Write(lock.command); e != nil {
return fmt.Errorf("failed to send command frame: %w", e)
}
// 4. Expect ACK to command (again drain any queued frames that might precede it)
if e := lock.waitForAck(conn, reader, timeout, &drained); e != nil {
return fmt.Errorf("error awaiting ACK to command: %w", e)
}
// 5. Now read the *next* STX frame which should be the response to our command.
for {
conn.SetReadDeadline(time.Now().Add(20 * time.Second))
b, e := reader.ReadByte()
if e != nil {
return fmt.Errorf("error reading response start: %w", e)
}
if b != STX {
// If anything else arrives, it might be another control byte or noise;
// log and keep consuming until we find STX.
log.Warnf("expected STX to start response but got 0x%X; ignoring", b)
continue
}
// consume full response frame
frame, fe := lock.readFrame(conn, reader, b, timeout)
if fe != nil {
return fmt.Errorf("error reading response frame: %w", fe)
}
resp = append(resp, frame...)
break
}
// parse the command code within response frame
if len(resp) >= 4 {
// The command bytes are usually at indices 2 and 3 if separator is at index 1
sepIndex := 1
b1 := resp[sepIndex+1]
b2 := resp[sepIndex+2]
switch {
case b1 == 'C' && b2 == 'N':
log.Infof("LockSequence: command response is CN (normal)")
case b1 == 'C' && b2 == 'C':
log.Infof("LockSequence: command response is CC (follow-up)")
case b1 == 'T' && b2 == 'D':
log.Warnf("LockSequence: command response is TD (room does not exist)")
return fmt.Errorf("lock response indicates room does not exist")
default:
log.Warnf("LockSequence: unexpected command response %q", string(resp))
return fmt.Errorf("error encoding keycard, unexpected response")
}
} else {
log.Warnf("LockSequence: response too short: %q", string(resp))
return fmt.Errorf("response too short")
}
log.Infof("LockSequence: received response: %q", string(resp))
return nil
}

View File

@ -0,0 +1,70 @@
package lockserver
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"time"
log "github.com/sirupsen/logrus"
)
// GuestCardResponse represents the JSON response from the GuestCard API call.
type GuestCardResponse struct {
Code string `json:"code"`
CardID string `json:"CardID"`
CommandID int `json:"CommandID"` // optional
Msg string `json:"Msg"` // error message if Code != "0"
}
func (lock *TLJLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
params := url.Values{}
params.Set("cer", Cert)
params.Set("room", doorReq.RoomField)
params.Set("BeginTime", checkIn.Format("2006-01-02 15:04:05"))
params.Set("EndTime", checkOut.Format("2006-01-02 15:04:05"))
switch doorReq.FollowStr {
case "0":
params.Set("CheckInMode", "new")
case "1":
params.Set("CheckInMode", "follow")
}
lock.command = fmt.Sprintf("%s/GuestCard?%s", LockServerURL, params.Encode())
return nil
}
func (lock *TLJLockServer) LockSequence() error {
log.Infof("Sending command: %q", lock.command)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(lock.command)
if err != nil {
return fmt.Errorf("HTTP request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %v", err)
}
// Parse response JSON
var result GuestCardResponse
err = json.Unmarshal(body, &result)
if err != nil {
return fmt.Errorf("invalid JSON response: %v", err)
}
if result.Code != "0" {
log.Printf("API error %s: %s", result.Code, result.Msg)
return fmt.Errorf("API error %s: %s", result.Code, result.Msg)
}
log.Printf("Guest card created successfully: CardID=%s, CommandID=%d", result.CardID, result.CommandID)
return nil
}

267
main.go
View File

@ -1,13 +1,17 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"net"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
"github.com/tarm/serial"
@ -17,6 +21,7 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
"gitea.futuresens.co.uk/futuresens/cmstypes"
@ -24,9 +29,10 @@ import (
)
const (
buildVersion = "0.9.1"
serviceName = "hardlink"
customLayout = "2006-01-02 15:04:05 -0700"
buildVersion = "1.0.18"
serviceName = "hardlink"
customLayout = "2006-01-02 15:04:05 -0700"
transactionUrl = "http://127.0.0.1:18181/start-transaction/"
)
// configRec holds values from config.yml.
@ -35,34 +41,36 @@ type configRec struct {
LockserverUrl string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"`
Cert string `yaml:"cert"`
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"`
IsPayment bool `yaml:"isPayment"`
}
// App holds shared resources.
type App struct {
configRec configRec
dispPort *serial.Port
lockConn net.Conn
config configRec
lockserver lockserver.LockServer
}
func newApp(dispPort *serial.Port, config configRec) *App {
return &App{
configRec: config,
dispPort: dispPort,
lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError),
}
}
func main() {
// Load config
config := readConfig()
printer.Layout = readTicketLayout()
printer.PrinterName = config.PrinterName
lockserver.Cert = config.Cert
lockserver.LockServerURL = config.LockserverUrl
// Setup logging and get file handle
logFile, err := setupLogging(config.LogDir)
@ -92,30 +100,79 @@ func main() {
}
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)
// Test lock-server connection
switch strings.ToLower(config.LockType) {
case lockserver.TLJ:
default:
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
if err != nil {
fatalError(err)
}
log.Infof("Connectting to lock server at %s", config.LockserverUrl)
lockConn.Close()
}
if config.IsPayment {
startClient := func() (*exec.Cmd, error) {
cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe")
err := cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to start ChipDnaClient: %v", err)
}
log.Infof("ChipDnaClient started with PID %d", cmd.Process.Pid)
return cmd, nil
}
cmd, err := startClient()
if err != nil {
fatalError(err)
}
// Restart loop
go func() {
for {
err := cmd.Wait()
if err != nil {
log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
time.Sleep(2 * time.Second)
cmd, err = startClient()
if err != nil {
log.Errorf("Restart failed: %v", err)
return
}
log.Info("ChipDnaClient restarted successfully")
}
}
}()
// Handle shutdown signals
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
log.Info("Shutting down...")
if cmd.Process != nil {
log.Info("Sending SIGTERM to ChipDnaClient...")
_ = cmd.Process.Signal(syscall.SIGTERM)
// wait up to 5s for graceful shutdown
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-time.After(5 * time.Second):
log.Warn("ChipDnaClient did not exit in time, killing...")
_ = cmd.Process.Kill()
case err := <-done:
log.Infof("ChipDnaClient exited cleanly: %v", err)
}
}
os.Exit(0)
}()
}
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)
}
// dispHandle := &serial.Port{} // Placeholder, replace with actual dispenser handle
app := newApp(dispHandle, config)
mux := http.NewServeMux()
setUpRoutes(app, mux)
@ -131,6 +188,7 @@ func main() {
func setUpRoutes(app *App, mux *http.ServeMux) {
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
mux.HandleFunc("/printroomticket", app.printRoomTicket)
mux.HandleFunc("/starttransaction", app.startTransaction)
}
func fatalError(err error) {
@ -174,10 +232,114 @@ func writeError(w http.ResponseWriter, status int, msg string) {
json.NewEncoder(w).Encode(theResponse)
}
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(serviceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
}
}
func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("startTransaction")
var (
theResponse cmstypes.ResponseRec
cardholderReceipt string
theRequest cmstypes.TransactionRec
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
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 !app.configRec.IsPayment {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("startTransaction called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Start trnasaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
responseEntries, _ := payment.ParseTransactionResult(body)
// Compose JSON from responseEntries
result := make(map[string]string)
for _, e := range responseEntries {
switch e.Key {
case payment.ReceiptData, payment.ReceiptDataMerchant:
// ignore these
case payment.ReceiptDataCardholder:
cardholderReceipt = e.Value
case payment.TransactionResult:
theResponse.Status.Message = e.Value
theResponse.Status.Code = http.StatusOK
result[e.Key] = e.Value
default:
result[e.Key] = e.Value
}
}
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Data = payment.BuildRedirectURL(result)
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("issueDoorCard")
var (
doorReq DoorCardRequest
doorReq lockserver.DoorCardRequest
theResponse cmstypes.StatusRec
)
@ -223,38 +385,25 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
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, err := dispenser.DispenserSequence(app.dispPort); err != nil {
if status != "" {
logging.Error(serviceName, status, "Dispenser error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser error: "+err.Error())
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
} else {
logging.Error(serviceName, err.Error(), "Dispenser error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, err.Error()+"; check card stock")
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
}
return
} else {
log.Info(status)
}
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)
}
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence
err = app.lockserver.LockSequence(app.lockConn)
err = app.lockserver.LockSequence()
if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
writeError(w, http.StatusBadGateway, err.Error())
@ -281,7 +430,6 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
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", "*")
@ -292,6 +440,7 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("printRoomTicket called")
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return

317
payment/creditcall.go Normal file
View File

@ -0,0 +1,317 @@
package payment
import (
"context"
"database/sql"
"encoding/hex"
"encoding/xml"
"fmt"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/denisenkom/go-mssqldb"
log "github.com/sirupsen/logrus"
)
const (
// Transaction types
SaleTransactionType = "sale"
AccountVerificationType = "account verification"
// Transaction results
ResultApproved = "approved"
ResultDeclined = "declined"
ResultCancelled = "cancelled"
ResultPending = "pending"
ResultError = "error"
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
CheckinUnsuccessfulEndpoint = "/unsuccessful"
// Response map keys
CardReference = "CARD_REFERENCE"
CardHash = "CARD_HASH"
Errors = "ERRORS"
ReceiptData = "RECEIPT_DATA"
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
Reference = "REFERENCE"
TransactionResult = "TRANSACTION_RESULT"
TransactionType = "TRANSACTION_TYPE"
ConfirmResult = "CONFIRM_RESULT"
ConfirmErrors = "CONFIRM_ERRORS"
// Log field keys
LogFieldError = "error"
LogFieldDescription = "description"
LogResult = "transactionResult"
)
// XML parsing structs
type (
TransactionRec struct {
XMLName xml.Name `xml:"TransactionPayload"`
AmountMinorUnits string `xml:"amount"`
TransactionType string `xml:"transactionType"`
}
TransactionResultXML struct {
XMLName xml.Name `xml:"TransactionResult"`
Entries []EntryXML `xml:"Entry"`
}
EntryXML struct {
Key string `xml:"Key"`
Value string `xml:"Value"`
}
TransactionConfirmation struct {
XMLName xml.Name `xml:"TransactionConfirmation"`
Result string `xml:"Result"`
Errors string `xml:"Errors"`
ErrorDescription string `xml:"ErrorDescription"`
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
}
)
// ParseTransactionResult parses the XML into entries.
func ParseTransactionResult(data []byte) ([]EntryXML, error) {
var tr TransactionResultXML
if err := xml.Unmarshal(data, &tr); err != nil {
return nil, fmt.Errorf("XML unmarshal: %w", err)
}
return tr.Entries, nil
}
// initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS
// using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase.
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
const server = "localhost"
// Use TCP; drop the \SQLEXPRESS instance name
connString := fmt.Sprintf(
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
user, password, server, port, database,
)
db, err := sql.Open("sqlserver", connString)
if err != nil {
return nil, fmt.Errorf("opening DB: %w", err)
}
// Verify connectivity
if err := db.PingContext(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("pinging DB: %w", err)
}
return db, nil
}
// insertTransactionRecord inserts one row into TransactionRecords.
// m is the map from keys to string values as returned by ChipDNA.
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error {
// Extract fields with defaults or NULL handling.
// 1. TxnReference <- REFERENCE
ref, ok := m["REFERENCE"]
if !ok || ref == "" {
return fmt.Errorf("missing REFERENCE in result map")
}
// 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now
var txnTime time.Time
if s, ok := m["AUTH_DATE_TIME"]; ok && s != "" {
t, err := time.ParseInLocation("20060102150405", s, time.UTC)
if err != nil {
// fallback: use now
txnTime = time.Now().UTC()
} else {
txnTime = t
}
} else {
txnTime = time.Now().UTC()
}
// 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100)
var totalAmount sql.NullFloat64
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
if iv, err := strconv.ParseInt(s, 10, 64); err == nil {
// convert minor units to major (e.g. 150 -> 1.50)
totalAmount.Float64 = float64(iv) / 100.0
totalAmount.Valid = true
}
}
// 4. MerchantId <- MERCHANT_ID_MASKED
merchantId := sql.NullString{String: m["MERCHANT_ID_MASKED"], Valid: m["MERCHANT_ID_MASKED"] != ""}
// 5. TerminalId <- TERMINAL_ID_MASKED
terminalId := sql.NullString{String: m["TERMINAL_ID_MASKED"], Valid: m["TERMINAL_ID_MASKED"] != ""}
// 6. CardSchemeName <- CARD_SCHEME
cardScheme := sql.NullString{String: m["CARD_SCHEME"], Valid: m["CARD_SCHEME"] != ""}
// 7. ExpiryDate <- EXPIRY_DATE
expiryDate := sql.NullString{String: m["EXPIRY_DATE"], Valid: m["EXPIRY_DATE"] != ""}
// 8. RecordReference <- CARD_REFERENCE
recordRef := sql.NullString{String: m["CARD_REFERENCE"], Valid: m["CARD_REFERENCE"] != ""}
// 9. Token1 <- CARD_HASH
token1 := sql.NullString{String: m["CARD_HASH"], Valid: m["CARD_HASH"] != ""}
// 10. Token2 <- CARDEASE_REFERENCE
token2 := sql.NullString{String: m["CARDEASE_REFERENCE"], Valid: m["CARDEASE_REFERENCE"] != ""}
// 11. PanMasked <- PAN_MASKED
panMasked := sql.NullString{String: m["PAN_MASKED"], Valid: m["PAN_MASKED"] != ""}
// 12. AuthCode <- AUTH_CODE
authCode := sql.NullString{String: m["AUTH_CODE"], Valid: m["AUTH_CODE"] != ""}
// 13. TransactionResult <- TRANSACTION_RESULT
txnResult := sql.NullString{String: m["TRANSACTION_RESULT"], Valid: m["TRANSACTION_RESULT"] != ""}
// Build INSERT statement with named parameters.
// Assuming your table is [TransactionDatabase].[dbo].[TransactionRecords].
const stmt = `
INSERT INTO [TransactionDatabase].[dbo].[TransactionRecords]
(
[TxnReference],
[TxnDateTime],
[TotalAmount],
[MerchantId],
[TerminalId],
[CardSchemeName],
[ExpiryDate],
[RecordReference],
[Token1],
[Token2],
[PanMasked],
[AuthCode],
[TransactionResult]
)
VALUES
(
@TxnReference,
@TxnDateTime,
@TotalAmount,
@MerchantId,
@TerminalId,
@CardSchemeName,
@ExpiryDate,
@RecordReference,
@Token1,
@Token2,
@PanMasked,
@AuthCode,
@TransactionResult
);
`
// Execute with sql.Named parameters:
_, err := db.ExecContext(ctx, stmt,
sql.Named("TxnReference", ref),
sql.Named("TxnDateTime", txnTime),
sql.Named("TotalAmount", nullableFloatArg(totalAmount)),
sql.Named("MerchantId", nullableStringArg(merchantId)),
sql.Named("TerminalId", nullableStringArg(terminalId)),
sql.Named("CardSchemeName", nullableStringArg(cardScheme)),
sql.Named("ExpiryDate", nullableStringArg(expiryDate)),
sql.Named("RecordReference", nullableStringArg(recordRef)),
sql.Named("Token1", nullableStringArg(token1)),
sql.Named("Token2", nullableStringArg(token2)),
sql.Named("PanMasked", nullableStringArg(panMasked)),
sql.Named("AuthCode", nullableStringArg(authCode)),
sql.Named("TransactionResult", nullableStringArg(txnResult)),
)
if err != nil {
return fmt.Errorf("insert TransactionRecords: %w", err)
}
// Successfully inserted
log.Infof("Inserted transaction record for reference %s", ref)
return nil
}
// Helpers to pass NULL when appropriate:
func nullableStringArg(ns sql.NullString) interface{} {
if ns.Valid {
return ns.String
}
return nil
}
func nullableFloatArg(nf sql.NullFloat64) interface{} {
if nf.Valid {
return nf.Float64
}
return nil
}
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
func BuildRedirectURL(result map[string]string) string {
res := result[TransactionResult]
tType := result[TransactionType]
// Transaction approved?
if strings.EqualFold(res, ResultApproved) {
switch {
// Transaction type AccountVerification?
case strings.EqualFold(tType, AccountVerificationType):
log.WithField(LogResult, result[TransactionResult]).
Info("Account verification approved")
return buildSuccessURL(result)
// Transaction type Sale?
case strings.EqualFold(tType, SaleTransactionType):
// Transaction confirmed?
if strings.EqualFold(result[ConfirmResult], ResultApproved) {
log.WithField(LogResult, result[ConfirmResult]).
Info("Transaction approved and confirmed")
return buildSuccessURL(result)
}
// Not confirmed
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
Error("Transaction approved but not confirmed")
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
}
}
// Not approved
return BuildFailureURL(res, result[Errors])
}
func buildSuccessURL(result map[string]string) string {
q := url.Values{}
q.Set("TxnReference", result[Reference])
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash])))
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference])))
return (&url.URL{
Path: CheckinSuccessfulEndpoint,
RawQuery: q.Encode(),
}).String()
}
func BuildFailureURL(msgType, description string) string {
q := url.Values{}
if msgType == "" {
msgType = ResultError
}
if description == "" {
description = "Transaction failed"
}
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
Error("Transaction failed")
q.Set("MsgType", msgType)
q.Set("Description", description)
return (&url.URL{
Path: CheckinUnsuccessfulEndpoint,
RawQuery: q.Encode(),
}).String()
}

View File

@ -5,13 +5,13 @@ import (
"encoding/xml"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
"github.com/alexbrainman/printer"
log "github.com/sirupsen/logrus"
@ -19,6 +19,21 @@ import (
)
type (
CardholderReceipt struct {
XMLName xml.Name `xml:"ReceiptData"`
ReceiptEntries struct {
ReceiptEntry []ReceiptEntryXML `xml:"ReceiptEntry"`
} `xml:"ReceiptEntries"`
}
ReceiptEntryXML struct {
ReceiptEntryId string `xml:"ReceiptEntryId"`
Value string `xml:"Value"`
Label string `xml:"Label"`
ReceiptItemType string `xml:"ReceiptItemType"`
Priority string `xml:"Priority"`
}
RoomDetailsRec struct {
XMLName xml.Name `xml:"roomdetails"`
Name string `xml:"customername"`
@ -54,11 +69,30 @@ type (
}
)
const (
ESC = 0x1B
GS = 0x1D
CENTER = 0x01
BOLD_ON = 0x01
BOLD_OFF = 0x00
LARGE_FONT = 0x11
WIDER_FONT = 0x10
NORMAL_FONT = 0x00
)
var (
Layout LayoutOptions
PrinterName string
)
func ParseCardholderReceipt(data []byte) ([]ReceiptEntryXML, error) {
var tr CardholderReceipt
if err := xml.Unmarshal(data, &tr); err != nil {
return nil, fmt.Errorf("XML unmarshal: %w", err)
}
return tr.ReceiptEntries.ReceiptEntry, nil
}
func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
var buf bytes.Buffer
@ -66,17 +100,6 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
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 {
@ -144,6 +167,114 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
return buf.Bytes(), nil
}
func PrintCardholderReceipt(cardholderReceipt string) error {
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
if err != nil {
return fmt.Errorf("ParseCardholderReceipt: %w", err)
}
data, err := BuildCardholderReceipt(receiptEntries)
if err != nil {
return fmt.Errorf("BuildCardholderReceipt: %w", err)
}
// Send to the Windows Epson TM-T82II via the printer package
if err := SendToPrinter(data); err != nil {
return fmt.Errorf("SendToPrinter: %w", err)
}
log.Info("Cardholder receipt printed successfully")
return nil
}
func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) {
var buf bytes.Buffer
write := func(b []byte) { buf.Write(b) }
// writeStr := func(s string) { buf.WriteString(s) }
writeln := func(s string) {
buf.WriteString(s)
buf.WriteByte('\n')
}
// Build a lookup map by ReceiptEntryId
m := make(map[string]ReceiptEntryXML, len(entries))
for _, e := range entries {
m[e.ReceiptEntryId] = e
}
// Utility to center text
// center := func(s string) {
// write([]byte{ESC, 'a', CENTER})
// writeln(s)
// write([]byte{ESC, 'a', 0})
// }
// Utility for bold lines
// boldOn := func() { write([]byte{GS, '!', WIDER_FONT}) }
// boldOff := func() { write([]byte{GS, '!', NORMAL_FONT}) }
// boldOn()
// 1) Header: CARDHOLDER COPY
writeln(m["Recipient"].Value)
// writeStr("\n\n")
// 2) Merchant name
writeln(m["MerchantName"].Value)
// writeStr("\n\n")
// 3) AID
writeln("AID: " + m["Aid"].Value)
// 4) Card scheme & pan
writeln(fmt.Sprintf("%s Card: %s", m["CardScheme"].Value, m["PanMasked"].Value))
// 5) PAN Seq No
writeln("PAN Seq No: " + m["PanSequence"].Value)
// 6) Transaction source
writeln(m["TransactionSource"].Value)
// 7) Transaction type (Sale/Account Verification)
writeln(m["TransactionType"].Value)
// 8) Total
// assuming value like "GBP1.50" — insert space after currency
total := m["TotalAmount"].Value
if !strings.HasPrefix(total, "GBP") && len(total) > 3 {
total = total[:3] + " " + total[3:]
}
writeln("TOTAL: " + total)
// 9) Cardholder verification
writeln(m["CardholderVerification"].Value)
// 10) Approved/Declined
writeln(m["TransactionResult"].Value)
// 11) Auth code
writeln("Auth Code: " + m["AuthCode"].Value)
// 12) Reference
writeln("Ref: " + m["AuthReference"].Value)
// 13) Merchant & terminal IDs
writeln("MID: " + m["MerchantIdMasked"].Value)
writeln("TID: " + m["TerminalIdMasked"].Value)
// 14) Date/time
writeln(m["AuthDateTime"].Value)
// 15) Retention message
writeln(m["RetentionMessage"].Value)
// boldOff()
// finally feed & cut
write([]byte{ESC, 'd', 7}) // Feed 5 lines
write([]byte{GS, 'V', 1})
return buf.Bytes(), nil
}
func printLogo(path string) ([]byte, error) {
const maxLogoWidth = 384
f, err := os.Open(path)

View File

@ -2,10 +2,56 @@
builtVersion is a const in main.go
#### 0.9.1 - 22 May 2024
#### 1.0.18 - 04 September 2025
increased timeout for TLJ lock server connection to 30 seconds
#### 1.0.17 - 30 August 2025
added functionality to commit transactions
#### 1.0.15 - 27 August 2025
fixed TCP/IP connection to the lock server
#### 1.0.14 - 21 August 2025
fixed issue in creditcall payment processing where error description was not properly set
#### 1.0.13 - 21 August 2025
TCP/IP connection to the lock server is now established before encoding the keycard and closedafter the encoding is done.
#### 1.0.12 - 11 August 2025
added delay before checking dispenser status
#### 1.0.11 - 11 August 2025
updated Salto key encoding workflow
#### 1.0.10 - 08 August 2025
updated logging for TLJ locks
#### 1.0.9 - 08 August 2025
added TLJ lock server and implemented workflow for TLJ locks
#### 1.0.8 - 01 August 2025
improved error handling and logging in Salto
#### 1.0.7 - 25 July 2025
added check if the room exists
#### 1.0.6 - 25 July 2025
updated workflow for Salto locks
#### 1.0.5 - 24 July 2025
added encoding keycard copy for Salto locks
#### 1.0.4 - 22 July 2025
added salto lock server and implemented workflow for Salto
#### 1.0.0 - 30 June 2025
added creditcall payment method
`/starttransaction` - API payment endpoint to start a transaction
#### 0.9.1 - 22 May 2025
added lockserver interface and implemented workflow for Omnitec
#### 0.9.0 - 22 May 2024
#### 0.9.0 - 22 May 2025
The new API has two new endpoints:
- `/issuedoorcard` - encoding the door card for the room.
- `/printroomticket` - printing the room ticket.