227 lines
6.5 KiB
Go
227 lines
6.5 KiB
Go
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
|
||
}
|