From fc91f2c0f2b10094464b535ad9d8a43ced6f4308 Mon Sep 17 00:00:00 2001 From: yurii Date: Fri, 25 Jul 2025 14:32:30 +0100 Subject: [PATCH] updated workflow for Salto locks --- lockserver/saltolockserver.go | 165 +++++++++++++++++++++------------- main.go | 3 +- release notes.md | 3 + 3 files changed, 105 insertions(+), 66 deletions(-) diff --git a/lockserver/saltolockserver.go b/lockserver/saltolockserver.go index 6fcfe28..b4f784c 100644 --- a/lockserver/saltolockserver.go +++ b/lockserver/saltolockserver.go @@ -1,113 +1,150 @@ package lockserver import ( + "bufio" "fmt" "net" "time" - // "strings" - 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 + 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 { - lrc := byte(0x00) + var lrc byte for _, b := range data { lrc ^= b } return lrc } -// simple join that avoids importing strings just for this. -func stringJoin(parts []string, sep string) string { - if len(parts) == 0 { - return "" - } - out := parts[0] - for _, p := range parts[1:] { - out += sep + p - } - return out -} - -// BuildCommand builds a Salto card‑issuance command in the form: -// STX|CN||E|| | | | | |||ETX| -// where and are hhmmDDMMYY, and LRC is the XOR of all bytes -func (lock *SaltoLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error { - // format helper: hhmmDDMMYY - var command string +// 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) - switch doorReq.FollowStr { - case "0": - command = "CN" // encode keycard - case "1": - command = "CC" // encode keycard copy - default: - command = "CN" + // command type + cmd := "CN" + if req.FollowStr == "1" { + cmd = "CC" } - // the 12 fields between STX and ETX + // fields 0–10 fields := []string{ - command, lock.encoderAddr, "E", doorReq.RoomField, "", "", "", "", "", "", start, expiry, + cmd, + lock.encoderAddr, + "E", + req.RoomField, + "", // optional field 4 + "", // 5 + "", // 6 + "", // 7 + "", // 8 + start, + expiry, } - body := "|" + stringJoin(fields, "|") + "|" // leading/trailing pipes, so the ETX ends the last field + // 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) - // wrap with STX/ETX - msg := append([]byte{STX}, []byte(body)...) - msg = append(msg, ETX) + // append LRC (XOR of everything after STX through ETX) + lrc := calculateLRC(body[1:]) + body = append(body, lrc) - // compute LRC over everything *after* STX up to and including ETX - lrc := calculateLRC(msg[1:]) // skip STX - msg = append(msg, lrc) - - lock.command = msg + lock.command = body return nil } -// Checks heart beat of the Assa Abloy lock server and perform key encoding +// LockSequence performs the full ENQ/ACK handshake and command exchange func (lock *SaltoLockServer) LockSequence(conn net.Conn) error { - const funcName = "SaltoLockServer.LockSequence" + const timeout = 10 * time.Second - // Step 1: ENQ to check availability - respStr, err := sendAndReceive(conn, []byte{ENQ}) + reader := bufio.NewReader(conn) + + // 1. Send ENQ + if _, err := conn.Write([]byte{ENQ}); err != nil { + return fmt.Errorf("failed to send ENQ: %w", err) + } + + // 2. Expect ACK + conn.SetReadDeadline(time.Now().Add(timeout)) + b, err := reader.ReadByte() if err != nil { - return fmt.Errorf("[%s] failed sending ENQ: %w", funcName, err) + return fmt.Errorf("error awaiting ACK to ENQ: %w", err) } - resp := []byte(respStr) - - if len(resp) != 1 || resp[0] != ACK { - return fmt.Errorf("[%s] expected ACK (0x06) after ENQ, got: %q (hex: % X)", funcName, respStr, resp) + if b != ACK { + return fmt.Errorf("expected ACK after ENQ, got 0x%X", b) } - // Step 2: Send actual command - respStr, err = sendAndReceive(conn, lock.command) + // 3. Send the command frame + if _, err := conn.Write(lock.command); err != nil { + return fmt.Errorf("failed to send command frame: %w", err) + } + + // 4. Expect ACK to command + conn.SetReadDeadline(time.Now().Add(timeout)) + b, err = reader.ReadByte() if err != nil { - return fmt.Errorf("[%s] failed sending command: %w", funcName, err) + return fmt.Errorf("error awaiting ACK to command: %w", err) } - resp = []byte(respStr) - - if len(resp) == 1 && resp[0] == NAK { - return fmt.Errorf("[%s] command rejected by lock server (NAK)", funcName) + if b == NAK { + return fmt.Errorf("command rejected (NAK)") + } else if b != ACK { + return fmt.Errorf("expected ACK to command, got 0x%X", b) } - log.Infof("Encoding response: %q", respStr) + // 5. Read response: expect STX + conn.SetReadDeadline(time.Now().Add(timeout)) + b, err = reader.ReadByte() + if err != nil { + return fmt.Errorf("error reading response STX: %w", err) + } + if b != STX { + return fmt.Errorf("expected STX at response start, got 0x%X", b) + } + + // 6. Read until ETX + var resp []byte + for { + conn.SetReadDeadline(time.Now().Add(timeout)) + c, err := reader.ReadByte() + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + resp = append(resp, c) + if c == ETX { + break + } + } + + // 7. (Optional) Read LRC or final CR + conn.SetReadDeadline(time.Now().Add(timeout)) + if lrc, err := reader.ReadByte(); err == nil { + resp = append(resp, lrc) + } else { + log.Warnf("LockSequence: failed to read trailing LRC/CR: %v", err) + } + + log.Infof("LockSequence: received response: % X", resp) return nil } - - diff --git a/main.go b/main.go index f79e355..8ac61f6 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ import ( ) const ( - buildVersion = "1.0.5" + buildVersion = "1.0.6" serviceName = "hardlink" customLayout = "2006-01-02 15:04:05 -0700" transactionUrl = "http://127.0.0.1:18181/start-transaction/" @@ -49,7 +49,6 @@ type configRec struct { isPayment bool `yaml:"isPayment"` } - // App holds shared resources. type App struct { dispPort *serial.Port diff --git a/release notes.md b/release notes.md index df41db9..4d00431 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.0.6 - 25 July 2024 +updated workflow for Salto locks + #### 1.0.5 - 24 July 2024 added encoding keycard copy for Salto locks