From b4d16f902134739ab5cd4ca691222af08a2dba98 Mon Sep 17 00:00:00 2001 From: yurii Date: Mon, 11 Aug 2025 14:57:33 +0100 Subject: [PATCH] updated Salto key encoding workflow --- lockserver/saltolockserver.go | 193 +++++++++++++++++++--------------- main.go | 2 +- release notes.md | 3 + 3 files changed, 113 insertions(+), 85 deletions(-) diff --git a/lockserver/saltolockserver.go b/lockserver/saltolockserver.go index 9ed5d1f..5e99861 100644 --- a/lockserver/saltolockserver.go +++ b/lockserver/saltolockserver.go @@ -75,120 +75,145 @@ func (lock *SaltoLockServer) BuildCommand(req DoorCardRequest, checkIn, checkOut 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(conn net.Conn) error { - log.Infof("Sending command: %q", string(lock.command)) - const timeout = 10 * time.Second var ( - err error - resp []byte + resp []byte + reader = bufio.NewReader(conn) + drained = 0 // count of stale frames consumed across waits ) - 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 - conn.SetReadDeadline(time.Now().Add(timeout)) - if b, e := reader.ReadByte(); e != nil { + // 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) - } else if b != ACK { - return fmt.Errorf("expected ACK after ENQ, got 0x%X", b) } // 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 - conn.SetReadDeadline(time.Now().Add(timeout)) - if b, e := reader.ReadByte(); e != nil { + // 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) - } else if b == NAK { - return fmt.Errorf("command rejected (NAK)") - } else if b != ACK { - return fmt.Errorf("expected ACK to command, got 0x%X", b) } - // 5. Expect STX - conn.SetReadDeadline(time.Now().Add(timeout)) - stx, e := reader.ReadByte() - if e != nil { - return fmt.Errorf("error reading STX: %w", e) - } - resp = append(resp, stx) - if stx != STX { - err = fmt.Errorf("expected STX, got 0x%X", stx) - } - - // 6. Read separator after STX - conn.SetReadDeadline(time.Now().Add(timeout)) - if sep, e := reader.ReadByte(); e == nil { - resp = append(resp, sep) - } else if err == nil { - err = fmt.Errorf("error reading separator after STX: %w", e) - } - - // 7. Read command code (e.g., CN, TD) - conn.SetReadDeadline(time.Now().Add(timeout)) - b1, e1 := reader.ReadByte() - if e1 == nil { - resp = append(resp, b1) - } else if err == nil { - err = fmt.Errorf("error reading first response byte: %w", e1) - } - b2, e2 := reader.ReadByte() - if e2 == nil { - resp = append(resp, b2) - } else if err == nil { - err = fmt.Errorf("error reading second response byte: %w", e2) - } - - 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)") - if err == nil { - err = fmt.Errorf("lock response indicates room does not exist") - } - default: - log.Warnf("LockSequence: unexpected command response %q", string(resp)) - if err == nil { - err = fmt.Errorf("error encoding keycard, unexpected response") - } - } - - // 8. Read rest of message until ETX + // 5. Now read the *next* STX frame which should be the response to our command. for { - conn.SetReadDeadline(time.Now().Add(timeout)) - c, e := reader.ReadByte() + conn.SetReadDeadline(time.Now().Add(20 * time.Second)) + b, e := reader.ReadByte() if e != nil { - if err == nil { - err = fmt.Errorf("error reading response body: %w", e) - } - break + return fmt.Errorf("error reading response start: %w", e) } - resp = append(resp, c) - if c == ETX { - break + 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 } - // 9. Optional: read trailing byte (LRC or CR) - conn.SetReadDeadline(time.Now().Add(timeout)) - if lrc, e := reader.ReadByte(); e == nil { - resp = append(resp, lrc) + // 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: failed to read trailing LRC/CR: %v", e) + log.Warnf("LockSequence: response too short: %q", string(resp)) + return fmt.Errorf("response too short") } log.Infof("LockSequence: received response: %q", string(resp)) - return err + return nil } diff --git a/main.go b/main.go index 60209fc..48c135f 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ import ( ) const ( - buildVersion = "1.0.10" + buildVersion = "1.0.11" serviceName = "hardlink" customLayout = "2006-01-02 15:04:05 -0700" transactionUrl = "http://127.0.0.1:18181/start-transaction/" diff --git a/release notes.md b/release notes.md index 9bab4cb..a42e16f 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.0.11 - 11 August 2024 +updated Salto key encoding workflow + #### 1.0.10 - 08 August 2024 updated logging for TLJ locks