From ffd814e076d0b3f1bc078e5b65108593a8fc2d9a Mon Sep 17 00:00:00 2001 From: yurii Date: Tue, 2 Jun 2026 10:10:17 +0100 Subject: [PATCH] added Dormakaba lock server integration --- cmd/hardlink/main.go | 10 +- config/config.go | 7 +- internal/dispenser/dispenserclient.go | 8 +- internal/lockserver/assalockserver.go | 2 +- internal/lockserver/dormakabalockserver.go | 291 +++++++++++++++++++++ internal/lockserver/lockservercommon.go | 18 +- internal/lockserver/omnilockserver.go | 12 +- release notes.md | 3 + 8 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 internal/lockserver/dormakabalockserver.go diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index 1db4956..762855e 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -29,7 +29,7 @@ import ( ) const ( - buildVersion = "1.2.8" + buildVersion = "1.2.9" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) @@ -40,7 +40,7 @@ func main() { printer.Layout = readTicketLayout() printer.PrinterName = cfg.PrinterName lockserver.Cert = cfg.Cert - lockserver.LockServerURL = cfg.LockserverUrl + lockserver.LockServerURL = cfg.LockserverURL mail.SendErrorEmails = cfg.SendErrorEmails // Root context for background goroutines @@ -94,14 +94,14 @@ func main() { case lockserver.TLJ: // TLJ uses HTTP - skip TCP probe here default: - lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) + lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverURL) if err != nil { fmt.Println(err.Error()) log.Errorf(err.Error()) mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error()) } else { - 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) + 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) lockConn.Close() } } diff --git a/config/config.go b/config/config.go index 8405b2f..cf97ea4 100644 --- a/config/config.go +++ b/config/config.go @@ -1,3 +1,4 @@ +// Package config handles reading and parsing configuration from config.yml. package config import ( @@ -10,10 +11,10 @@ import ( yaml "gopkg.in/yaml.v3" ) -// configRec holds values from config.yml. +// ConfigRec holds values from config.yml. type ConfigRec struct { Port int `yaml:"port"` - LockserverUrl string `yaml:"lockservUrl"` + LockserverURL string `yaml:"lockservUrl"` LockType string `yaml:"lockType"` EncoderAddress string `yaml:"encoderAddr"` Cert string `yaml:"cert"` @@ -32,7 +33,7 @@ type ConfigRec struct { SendErrorEmails []string `yaml:"senderroremails"` } -// ReadConfig reads config.yml and applies defaults. +// ReadHardlinkConfig reads config.yml and applies defaults. func ReadHardlinkConfig() ConfigRec { var cfg ConfigRec const configName = "config.yml" diff --git a/internal/dispenser/dispenserclient.go b/internal/dispenser/dispenserclient.go index a39f8a9..c266944 100644 --- a/internal/dispenser/dispenserclient.go +++ b/internal/dispenser/dispenserclient.go @@ -1,6 +1,4 @@ -// -------------------- -// Queue-based client (single owner of port) -// -------------------- +// Package dispenser provides a queue-based client (single owner of port). package dispenser 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) { c.mu.Lock() c.statusTTL = d @@ -233,7 +231,7 @@ func (c *Client) OutOfMouth(ctx context.Context) error { // 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) { const funcName = "DispenserPrepare" stockStatus := "" diff --git a/internal/lockserver/assalockserver.go b/internal/lockserver/assalockserver.go index 6794c8b..e8a8bb6 100644 --- a/internal/lockserver/assalockserver.go +++ b/internal/lockserver/assalockserver.go @@ -19,7 +19,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check 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 { const funcName = "AssaLockServer.LockSequence" diff --git a/internal/lockserver/dormakabalockserver.go b/internal/lockserver/dormakabalockserver.go new file mode 100644 index 0000000..5fe7be0 --- /dev/null +++ b/internal/lockserver/dormakabalockserver.go @@ -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 +} \ No newline at end of file diff --git a/internal/lockserver/lockservercommon.go b/internal/lockserver/lockservercommon.go index 49d6529..d16ff4e 100644 --- a/internal/lockserver/lockservercommon.go +++ b/internal/lockserver/lockservercommon.go @@ -25,10 +25,11 @@ const ( Omnitec = "omnitec" Salto = "salto" TLJ = "tlj" + Dormakaba = "kaba" ) var ( - Cert string + Cert string LockServerURL string ) @@ -38,6 +39,11 @@ type ( LockSequence() error } + KabaLockServer struct { + encoderAddr string + command []byte + } + AssaLockServer struct { encoderAddr string command string @@ -69,22 +75,24 @@ func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockSer return &SaltoLockServer{encoderAddr: encoderAddr} case TLJ: return &TLJLockServer{encoderAddr: encoderAddr} + case Dormakaba: + return &KabaLockServer{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) { +func InitializeServerConnection(LockserverURL string) (net.Conn, error) { const funcName = "InitializeServerConnection" // Parse the URL to extract host and port - parsedUrl, err := url.Parse(LockserverUrl) + parsedURL, err := url.Parse(LockserverURL) 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 - address := strings.Trim(parsedUrl.Host, "/") + address := strings.Trim(parsedURL.Host, "/") // Establish a TCP connection to the Visionline server conn, err := net.Dial("tcp", address) diff --git a/internal/lockserver/omnilockserver.go b/internal/lockserver/omnilockserver.go index 10beb91..a64940f 100644 --- a/internal/lockserver/omnilockserver.go +++ b/internal/lockserver/omnilockserver.go @@ -7,12 +7,12 @@ import ( "os" "strconv" "strings" - "time" + "time" 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 { const funcName = "OmniLockServer.BuildCommand" hostname, err := os.Hostname() @@ -25,7 +25,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check if err != nil { 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 dt := checkOut.Format("15:04") // DT = HH:mm @@ -37,7 +37,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check 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, + formattedLockID, hostname, dt, ga, @@ -52,7 +52,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check 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 { const funcName = "OmniLockServer.LockSequence" @@ -136,4 +136,4 @@ func parseOmniResponse(raw string) (string, error) { return "", fmt.Errorf("negative response code: %s", clean) } return "Success: " + clean, nil -} \ No newline at end of file +} diff --git a/release notes.md b/release notes.md index 0179d4b..ebfe3ba 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.2.9 - 02 June 2026 +added Dormakaba lock server integration + #### 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.