added Dormakaba lock server integration

This commit is contained in:
yurii 2026-06-02 10:10:17 +01:00
parent b982698ccd
commit ffd814e076
8 changed files with 326 additions and 25 deletions

View File

@ -29,7 +29,7 @@ import (
) )
const ( const (
buildVersion = "1.2.8" buildVersion = "1.2.9"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second pollingFrequency = 8 * time.Second
) )
@ -40,7 +40,7 @@ func main() {
printer.Layout = readTicketLayout() printer.Layout = readTicketLayout()
printer.PrinterName = cfg.PrinterName printer.PrinterName = cfg.PrinterName
lockserver.Cert = cfg.Cert lockserver.Cert = cfg.Cert
lockserver.LockServerURL = cfg.LockserverUrl lockserver.LockServerURL = cfg.LockserverURL
mail.SendErrorEmails = cfg.SendErrorEmails mail.SendErrorEmails = cfg.SendErrorEmails
// Root context for background goroutines // Root context for background goroutines
@ -94,14 +94,14 @@ func main() {
case lockserver.TLJ: case lockserver.TLJ:
// TLJ uses HTTP - skip TCP probe here // TLJ uses HTTP - skip TCP probe here
default: default:
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverURL)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
log.Errorf(err.Error()) log.Errorf(err.Error())
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error()) mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error())
} else { } else {
fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverUrl) fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverURL)
log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverUrl) log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverURL)
lockConn.Close() lockConn.Close()
} }
} }

View File

@ -1,3 +1,4 @@
// Package config handles reading and parsing configuration from config.yml.
package config package config
import ( import (
@ -10,10 +11,10 @@ import (
yaml "gopkg.in/yaml.v3" yaml "gopkg.in/yaml.v3"
) )
// configRec holds values from config.yml. // ConfigRec holds values from config.yml.
type ConfigRec struct { type ConfigRec struct {
Port int `yaml:"port"` Port int `yaml:"port"`
LockserverUrl string `yaml:"lockservUrl"` LockserverURL string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"` LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"` EncoderAddress string `yaml:"encoderAddr"`
Cert string `yaml:"cert"` Cert string `yaml:"cert"`
@ -32,7 +33,7 @@ type ConfigRec struct {
SendErrorEmails []string `yaml:"senderroremails"` SendErrorEmails []string `yaml:"senderroremails"`
} }
// ReadConfig reads config.yml and applies defaults. // ReadHardlinkConfig reads config.yml and applies defaults.
func ReadHardlinkConfig() ConfigRec { func ReadHardlinkConfig() ConfigRec {
var cfg ConfigRec var cfg ConfigRec
const configName = "config.yml" const configName = "config.yml"

View File

@ -1,6 +1,4 @@
// -------------------- // Package dispenser provides a queue-based client (single owner of port).
// Queue-based client (single owner of port)
// --------------------
package dispenser package dispenser
import ( import (
@ -74,7 +72,7 @@ func (c *Client) Close() {
} }
} }
// Optional: tune cache TTL (how "fresh" cached status must be) // SetStatusTTL sets the duration for which cached status is considered fresh.
func (c *Client) SetStatusTTL(d time.Duration) { func (c *Client) SetStatusTTL(d time.Duration) {
c.mu.Lock() c.mu.Lock()
c.statusTTL = d c.statusTTL = d
@ -233,7 +231,7 @@ func (c *Client) OutOfMouth(ctx context.Context) error {
// Public sequences updated to use Client (queue) // Public sequences updated to use Client (queue)
// -------------------- // --------------------
// DispenserPrepare: check status; if empty => ok; else ensure at encoder. // DispenserPrepare checks status; if empty => ok; else ensure at encoder.
func (c *Client) DispenserPrepare(ctx context.Context) (string, error) { func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
const funcName = "DispenserPrepare" const funcName = "DispenserPrepare"
stockStatus := "" stockStatus := ""

View File

@ -19,7 +19,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
return nil return nil
} }
// Checks heart beat of the Assa Abloy lock server and perform key encoding // LockSequence checks heartbeat of the Assa Abloy lock server and performs key encoding
func (lock *AssaLockServer) LockSequence() error { func (lock *AssaLockServer) LockSequence() error {
const funcName = "AssaLockServer.LockSequence" const funcName = "AssaLockServer.LockSequence"

View File

@ -0,0 +1,291 @@
package lockserver
import (
"bufio"
"fmt"
"net"
"net/url"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
kabaSTX = 0x02
kabaETX = 0x03
kabaACK = 0x06
kabaNAK = 0x15
)
// BuildCommand builds a key encoding request command for the dormakaba/Kaba lock server.
// KR|KTD|WS192.168.135.20|KC2|RN41|KO000000|GA241213|TI16:56|GD241214|DT11:00|G#75|
func (lock *KabaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
const funcName = "DormakabaLockServer.BuildCommand"
room := strings.TrimSpace(doorReq.RoomField)
if room == "" {
return fmt.Errorf("[%s] roomField is required", funcName)
}
if checkIn.IsZero() {
return fmt.Errorf("[%s] checkin time is required", funcName)
}
if checkOut.IsZero() {
return fmt.Errorf("[%s] checkout time is required", funcName)
}
ws := dormakabaWorkstationID()
ga := checkIn.Format("060102") // yyMMdd, example: 241213
gd := checkOut.Format("060102") // yyMMdd, example: 241214
ti := checkIn.Format("15:04") // HH:mm, example: 16:56
dt := checkOut.Format("15:04") // HH:mm, example: 11:00
payload := fmt.Sprintf(
"KR|KTD|WS%s|KC%s|RN%s|KO000000|GA%s|TI%s|GD%s|DT%s|G#75|",
ws,
lock.encoderAddr,
room,
ga,
ti,
gd,
dt,
)
lock.command = wrapKabaFrame(payload)
return nil
}
// LockSequence starts the link and performs key encoding.
func (lock *KabaLockServer) LockSequence() error {
const funcName = "KabaLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
reader := bufio.NewReader(conn)
regs, err := lock.linkStart(conn, reader)
if err != nil {
return fmt.Errorf("[%s] linkStart failed: %v", funcName, err)
}
for _, reg := range regs {
log.Printf("Received: %q", reg)
}
raw, err := lock.requestEncoding(conn, reader)
if err != nil {
return fmt.Errorf("[%s] request encoding failed: %v", funcName, err)
}
log.Infof("Encoding response: %s", raw)
return nil
}
// linkStart sends the dormakaba/Kaba LS command.
// LS|DA241213|TI165607|WS192.168.135.20|PW1234|
func (lock *KabaLockServer) linkStart(conn net.Conn, reader *bufio.Reader) ([]string, error) {
ws := dormakabaWorkstationID()
pw := dormakabaPassword()
payload := fmt.Sprintf(
"LS|DA%s|TI%s|WS%s|PW%s|",
time.Now().Format("060102"), // yyMMdd
time.Now().Format("150405"), // HHmmss
ws,
pw,
)
command := wrapKabaFrame(payload)
log.Printf("Sending Link Start command: %q", command)
if _, err := conn.Write(command); err != nil {
return nil, fmt.Errorf("failed to send Link Start command: %v", err)
}
var registers []string
timeout := 10 * time.Second
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, err := reader.ReadByte()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
if len(registers) > 0 {
return registers, nil
}
}
return nil, fmt.Errorf("error reading Link Start response: %v", err)
}
switch b {
case kabaACK:
registers = append(registers, "ACK")
continue
case kabaNAK:
return registers, fmt.Errorf("received NAK after Link Start")
case kabaSTX:
frame, err := readKabaFrame(conn, reader, b, timeout)
if err != nil {
return registers, fmt.Errorf("failed to read Link Start frame: %v", err)
}
frameText := string(frame)
registers = append(registers, frameText)
clean := cleanKabaFrame(frameText)
if strings.HasPrefix(clean, "LA|") {
return registers, nil
}
default:
log.Warnf("Ignoring unexpected byte during Link Start: 0x%X", b)
}
}
}
func (lock *KabaLockServer) requestEncoding(conn net.Conn, reader *bufio.Reader) (string, error) {
log.Printf("Sending Encoding command: %q", lock.command)
if _, err := conn.Write(lock.command); err != nil {
return "", fmt.Errorf("failed to send Encoding command: %v", err)
}
deadline := time.Now().Add(60 * time.Second)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return "", fmt.Errorf("timeout waiting for dormakaba encoding response")
}
conn.SetReadDeadline(time.Now().Add(remaining))
b, err := reader.ReadByte()
if err != nil {
return "", fmt.Errorf("error reading encoding response: %v", err)
}
switch b {
case kabaACK:
log.Debug("Received ACK after Encoding command")
continue
case kabaNAK:
return "", fmt.Errorf("received NAK after Encoding command")
case kabaSTX:
frame, err := readKabaFrame(conn, reader, b, 60*time.Second)
if err != nil {
return "", fmt.Errorf("failed to read encoding response frame: %v", err)
}
raw := string(frame)
clean := cleanKabaFrame(raw)
log.Printf("Received Encoding frame: %q", clean)
if strings.HasPrefix(clean, "KA|") {
return parseDormakabaEncodingResponse(clean)
}
log.Warnf("Ignoring non-KA frame while waiting for encoding result: %q", clean)
default:
log.Warnf("Ignoring unexpected byte while waiting for encoding response: 0x%X", b)
}
}
}
func parseDormakabaEncodingResponse(clean string) (string, error) {
if strings.Contains(clean, "|ASOK|") {
return "Success: " + clean, nil
}
if strings.Contains(clean, "|AS") {
return "", fmt.Errorf("negative dormakaba response: %s", clean)
}
return "", fmt.Errorf("unexpected dormakaba response: %s", clean)
}
func readKabaFrame(conn net.Conn, reader *bufio.Reader, firstByte byte, timeout time.Duration) ([]byte, error) {
frame := []byte{firstByte}
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, err := reader.ReadByte()
if err != nil {
return frame, fmt.Errorf("error reading frame body: %w", err)
}
frame = append(frame, b)
if b == kabaETX {
return frame, nil
}
}
}
func wrapKabaFrame(payload string) []byte {
command := make([]byte, 0, len(payload)+2)
command = append(command, kabaSTX)
command = append(command, []byte(payload)...)
command = append(command, kabaETX)
return command
}
func cleanKabaFrame(raw string) string {
return strings.Trim(raw, string([]byte{kabaSTX, kabaETX}))
}
func dormakabaPassword() string {
if strings.TrimSpace(Cert) != "" {
return strings.TrimSpace(Cert)
}
return "1234"
}
func dormakabaWorkstationID() string {
parsed, err := url.Parse(LockServerURL)
if err == nil && parsed.Host != "" {
host := parsed.Host
if h, _, splitErr := net.SplitHostPort(host); splitErr == nil {
return h
}
return strings.Trim(host, "/")
}
raw := strings.TrimSpace(LockServerURL)
raw = strings.TrimPrefix(raw, "http://")
raw = strings.TrimPrefix(raw, "https://")
raw = strings.Trim(raw, "/")
if h, _, splitErr := net.SplitHostPort(raw); splitErr == nil {
return h
}
if idx := strings.Index(raw, ":"); idx >= 0 {
return raw[:idx]
}
return raw
}

View File

@ -25,10 +25,11 @@ const (
Omnitec = "omnitec" Omnitec = "omnitec"
Salto = "salto" Salto = "salto"
TLJ = "tlj" TLJ = "tlj"
Dormakaba = "kaba"
) )
var ( var (
Cert string Cert string
LockServerURL string LockServerURL string
) )
@ -38,6 +39,11 @@ type (
LockSequence() error LockSequence() error
} }
KabaLockServer struct {
encoderAddr string
command []byte
}
AssaLockServer struct { AssaLockServer struct {
encoderAddr string encoderAddr string
command string command string
@ -69,22 +75,24 @@ func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockSer
return &SaltoLockServer{encoderAddr: encoderAddr} return &SaltoLockServer{encoderAddr: encoderAddr}
case TLJ: case TLJ:
return &TLJLockServer{encoderAddr: encoderAddr} return &TLJLockServer{encoderAddr: encoderAddr}
case Dormakaba:
return &KabaLockServer{encoderAddr: encoderAddr}
default: default:
fatalError(fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", lockType)) 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 return nil // This line will never be reached, but is needed to satisfy the compiler
} }
} }
func InitializeServerConnection(LockserverUrl string) (net.Conn, error) { func InitializeServerConnection(LockserverURL string) (net.Conn, error) {
const funcName = "InitializeServerConnection" const funcName = "InitializeServerConnection"
// Parse the URL to extract host and port // Parse the URL to extract host and port
parsedUrl, err := url.Parse(LockserverUrl) parsedURL, err := url.Parse(LockserverURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("[%s] failed to parse LockserverUrl: %v", funcName, err) return nil, fmt.Errorf("[%s] failed to parse LockserverURL: %v", funcName, err)
} }
// Remove any leading/trailing slashes just in case // Remove any leading/trailing slashes just in case
address := strings.Trim(parsedUrl.Host, "/") address := strings.Trim(parsedURL.Host, "/")
// Establish a TCP connection to the Visionline server // Establish a TCP connection to the Visionline server
conn, err := net.Dial("tcp", address) conn, err := net.Dial("tcp", address)

View File

@ -7,12 +7,12 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// Build key encoding request command for the Omnitec lock server. // BuildCommand builds key encoding request command for the Omnitec lock server.
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error { func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
const funcName = "OmniLockServer.BuildCommand" const funcName = "OmniLockServer.BuildCommand"
hostname, err := os.Hostname() hostname, err := os.Hostname()
@ -25,7 +25,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
if err != nil { if err != nil {
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err) return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
} }
formattedLockId := fmt.Sprintf("%04d", idInt) formattedLockID := fmt.Sprintf("%04d", idInt)
// Format date/time parts // Format date/time parts
dt := checkOut.Format("15:04") // DT = HH:mm dt := checkOut.Format("15:04") // DT = HH:mm
@ -37,7 +37,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
payload := fmt.Sprintf( payload := fmt.Sprintf(
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|", "KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
lock.encoderAddr, lock.encoderAddr,
formattedLockId, formattedLockID,
hostname, hostname,
dt, dt,
ga, ga,
@ -52,7 +52,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
return nil return nil
} }
// Starts link to the Omnitec lock server and perform key encoding // LockSequence starts link to the Omnitec lock server and perform key encoding
func (lock *OmniLockServer) LockSequence() error { func (lock *OmniLockServer) LockSequence() error {
const funcName = "OmniLockServer.LockSequence" const funcName = "OmniLockServer.LockSequence"
@ -136,4 +136,4 @@ func parseOmniResponse(raw string) (string, error) {
return "", fmt.Errorf("negative response code: %s", clean) return "", fmt.Errorf("negative response code: %s", clean)
} }
return "Success: " + clean, nil return "Success: " + clean, nil
} }

View File

@ -2,6 +2,9 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.2.9 - 02 June 2026
added Dormakaba lock server integration
#### 1.2.8 - 14 May 2026 #### 1.2.8 - 14 May 2026
Updated hardlink source layout to use cmd/hardlink for the main application entry point and internal/ for application packages. Runtime files and preauth-release layout remain unchanged. No functional changes. Updated hardlink source layout to use cmd/hardlink for the main application entry point and internal/ for application packages. Runtime files and preauth-release layout remain unchanged. No functional changes.