Compare commits
18 Commits
0.9.6
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
efa415e631 | |||
f1dc0ccce4 | |||
1bceb55285 | |||
bb8cdb1d84 | |||
251afd6aeb | |||
e6ff292706 | |||
b4d16f9021 | |||
c8f6c57983 | |||
61b089fe55 | |||
8b0e7df582 | |||
25eab0dc75 | |||
fc91f2c0f2 | |||
5ce9fdcf0b | |||
b303721a92 | |||
dc91a9ae63 | |||
b303481b5f | |||
41cf71a253 | |||
5e99453eeb |
1
.gitignore
vendored
1
.gitignore
vendored
@ -28,6 +28,7 @@ Checkin.code-workspace
|
||||
_obj
|
||||
_test
|
||||
.vscode/
|
||||
ChipDNAClient/
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
|
@ -251,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 {
|
||||
@ -281,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
10
go.mod
@ -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
34
go.sum
@ -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=
|
||||
|
@ -10,17 +10,24 @@ import (
|
||||
)
|
||||
|
||||
// Build key encoding request command for the Assa Abloy lock server.
|
||||
func (lock *AssaLockServer) BuildCommand(lockId string, checkIn, checkOut time.Time) error {
|
||||
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, lockId, co, ci)
|
||||
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(conn net.Conn) error {
|
||||
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)
|
||||
|
@ -12,15 +12,30 @@ import (
|
||||
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(lockId string, checkIn, checkOut time.Time) error
|
||||
LockSequence(conn net.Conn) error
|
||||
BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error
|
||||
LockSequence() error
|
||||
}
|
||||
|
||||
AssaLockServer struct {
|
||||
@ -29,17 +44,31 @@ type (
|
||||
}
|
||||
|
||||
OmniLockServer struct {
|
||||
encoderAddr string // Encoder address for the lock server
|
||||
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 lockType {
|
||||
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
|
||||
@ -84,8 +113,3 @@ func sendAndReceive(conn net.Conn, command []byte) (string, error) {
|
||||
response := buf[:n]
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// Build key encoding request command for the Omnitec lock server.
|
||||
func (lock *OmniLockServer) BuildCommand(lockId string, checkIn, checkOut time.Time) error {
|
||||
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||
const funcName = "OmniLockServer.BuildCommand"
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
@ -21,7 +21,7 @@ func (lock *OmniLockServer) BuildCommand(lockId string, checkIn, checkOut time.T
|
||||
}
|
||||
|
||||
// Format lockId as 4-digit zero-padded string
|
||||
idInt, err := strconv.Atoi(lockId)
|
||||
idInt, err := strconv.Atoi(doorReq.RoomField)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
|
||||
}
|
||||
@ -53,8 +53,15 @@ func (lock *OmniLockServer) BuildCommand(lockId string, checkIn, checkOut time.T
|
||||
}
|
||||
|
||||
// Starts link to the Omnitec lock server and perform key encoding
|
||||
func (lock *OmniLockServer) LockSequence(conn net.Conn) error {
|
||||
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 {
|
||||
|
226
lockserver/saltolockserver.go
Normal file
226
lockserver/saltolockserver.go
Normal 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 0–10
|
||||
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
|
||||
}
|
70
lockserver/tljlockserver.go
Normal file
70
lockserver/tljlockserver.go
Normal 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
|
||||
}
|
211
main.go
211
main.go
@ -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.2"
|
||||
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,31 +41,25 @@ 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
|
||||
lockserver lockserver.LockServer
|
||||
}
|
||||
|
||||
func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec) *App {
|
||||
func newApp(dispPort *serial.Port, config configRec) *App {
|
||||
return &App{
|
||||
configRec: config,
|
||||
dispPort: dispPort,
|
||||
lockConn: lockConn,
|
||||
lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError),
|
||||
}
|
||||
}
|
||||
@ -67,9 +67,10 @@ func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec) *App {
|
||||
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)
|
||||
@ -99,16 +100,79 @@ func main() {
|
||||
}
|
||||
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
|
||||
|
||||
// Initialize lock-server connection once
|
||||
// Test lock-server connection
|
||||
switch strings.ToLower(config.LockType) {
|
||||
case lockserver.TLJ:
|
||||
|
||||
default:
|
||||
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
|
||||
if err != nil {
|
||||
fatalError(err)
|
||||
}
|
||||
defer lockConn.Close()
|
||||
log.Infof("Connected to lock server at %s", config.LockserverUrl)
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
// Create App and wire routes
|
||||
app := newApp(dispHandle, lockConn, config)
|
||||
// dispHandle := &serial.Port{} // Placeholder, replace with actual dispenser handle
|
||||
app := newApp(dispHandle, config)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
setUpRoutes(app, mux)
|
||||
@ -124,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) {
|
||||
@ -167,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
|
||||
)
|
||||
|
||||
@ -231,10 +400,10 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq.RoomField, checkIn, checkOut)
|
||||
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())
|
||||
|
@ -1,42 +1,317 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/denisenkom/go-mssqldb"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// StartTransaction sends a start transaction XML to ChipDNA Server
|
||||
func (pc *PaymentClient) StartTransaction(amountMinorUnits int, reference string) (string, error) {
|
||||
conn, err := net.Dial("tcp", pc.Addr)
|
||||
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 "", fmt.Errorf("failed to connect: %w", err)
|
||||
return nil, fmt.Errorf("opening DB: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Set timeout
|
||||
conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
// Verify connectivity
|
||||
if err := db.PingContext(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("pinging DB: %w", err)
|
||||
}
|
||||
|
||||
// Format XML request
|
||||
request := fmt.Sprintf(`
|
||||
<StartTransaction>
|
||||
<Amount>%d</Amount>
|
||||
<AmountType>Actual</AmountType>
|
||||
<Reference>%s</Reference>
|
||||
<TransactionType>Sale</TransactionType>
|
||||
</StartTransaction>`, amountMinorUnits, reference)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte(request))
|
||||
// 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 {
|
||||
return "", fmt.Errorf("failed to write to server: %w", err)
|
||||
// fallback: use now
|
||||
txnTime = time.Now().UTC()
|
||||
} else {
|
||||
txnTime = t
|
||||
}
|
||||
} else {
|
||||
txnTime = time.Now().UTC()
|
||||
}
|
||||
|
||||
// Read response
|
||||
buff := make([]byte, 4096)
|
||||
n, err := conn.Read(buff)
|
||||
// 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("failed to read from server: %w", err)
|
||||
return fmt.Errorf("insert TransactionRecords: %w", err)
|
||||
}
|
||||
// Successfully inserted
|
||||
log.Infof("Inserted transaction record for reference %s", ref)
|
||||
return nil
|
||||
}
|
||||
|
||||
return string(buff[:n]), 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()
|
||||
}
|
||||
|
@ -1,32 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PaymentClient holds connection data
|
||||
type PaymentClient struct {
|
||||
Addr string // e.g., "127.0.0.1:1869"
|
||||
}
|
||||
|
||||
func InitializeConnection(paymentrUrl string) (net.Conn, error) {
|
||||
const funcName = "InitializeServerConnection"
|
||||
// Parse the URL to extract host and port
|
||||
parsedUrl, err := url.Parse(paymentrUrl)
|
||||
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 payment server: %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
@ -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,18 +69,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
@ -77,6 +80,26 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||||
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
|
||||
|
||||
// shortcuts
|
||||
write := func(b []byte) { buf.Write(b) }
|
||||
writeStr := func(s string) { buf.WriteString(s) }
|
||||
|
||||
// 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)
|
||||
|
@ -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.
|
Loading…
x
Reference in New Issue
Block a user