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(lock.encoderAddr) 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 }