Browse Source

feat: UHF读卡器连接管理与串口/TCP链路完善

- 连接状态管理与连接级测试补齐;串口/TCP 读卡器链路稳定性优化
lq 9 hours ago
parent
commit
4f863c1f4b

+ 8 - 7
internal/service/uhf/conn_manager.go

@@ -1,9 +1,10 @@
 package uhf
 
 import (
-	"log"
-	"wails-app/internal/global"
 	"sync"
+	"wails-app/internal/global"
+
+	"go.uber.org/zap"
 )
 
 // ConnManager 全局多设备连接管理器(单例)
@@ -30,7 +31,7 @@ func (m *connManager) AddConn(deviceCode string, conn UHFReaderConn) {
 	m.connMap[deviceCode] = conn
 	// 启动消息监听协程(异步接收设备数据)
 	go m.listenDeviceData(deviceCode, conn)
-	log.Printf("设备[%s]已加入连接池", deviceCode)
+	global.GVA_LOG.Info("UHF 设备已加入连接池", zap.String("device_code", deviceCode))
 }
 
 // GetConn 获取指定设备连接(多设备核心:按DeviceCode检索)
@@ -58,7 +59,7 @@ func (m *connManager) RemoveConn(deviceCode string) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	delete(m.connMap, deviceCode)
-	log.Printf("设备[%s]已移出连接池", deviceCode)
+	global.GVA_LOG.Info("UHF 设备已移出连接池", zap.String("device_code", deviceCode))
 }
 
 // listenDeviceData 异步监听设备数据(多设备消息接收核心)
@@ -66,7 +67,7 @@ func (m *connManager) listenDeviceData(deviceCode string, conn UHFReaderConn) {
 	for {
 		// 检查连接是否有效
 		if !conn.IsConnected() {
-			log.Printf("设备[%s]连接已断开,停止监听", deviceCode)
+			global.GVA_LOG.Warn("UHF 设备连接已断开,停止监听", zap.String("device_code", deviceCode))
 			// 更新数据库状态
 			//var device dao.UHFReader
 			global.GVA_DB.Where("device_code = ?", deviceCode).Update("status", "offline")
@@ -76,7 +77,7 @@ func (m *connManager) listenDeviceData(deviceCode string, conn UHFReaderConn) {
 		// 阻塞读取设备数据(TCP/串口统一通过ReadData读取)
 		data, err := conn.ReadData() // 新增ReadData接口方法
 		if err != nil {
-			log.Printf("设备[%s]读取数据失败:%v", deviceCode, err)
+			global.GVA_LOG.Warn("UHF 设备读取数据失败", zap.String("device_code", deviceCode), zap.Error(err))
 			continue
 		}
 
@@ -85,7 +86,7 @@ func (m *connManager) listenDeviceData(deviceCode string, conn UHFReaderConn) {
 			continue
 		}
 
-		log.Printf("设备[%s]收到数据:%x", deviceCode, data)
+		global.GVA_LOG.Debug("UHF 设备收到数据帧", zap.String("device_code", deviceCode), zap.Int("bytes", len(data)), zap.ByteString("payload", data))
 		// 触发回调处理数据(业务层自定义逻辑)
 		if m.recvCallback != nil {
 			m.recvCallback(deviceCode, data)

+ 44 - 0
internal/service/uhf/connection_status_test.go

@@ -0,0 +1,44 @@
+package uhf
+
+import (
+	"testing"
+	"time"
+)
+
+func TestTCPReaderIsConnectedDoesNotWaitForIOLock(t *testing.T) {
+	reader := NewTCPReader("127.0.0.1", 9000)
+	reader.connected.Store(true)
+	reader.ioMu.Lock()
+	defer reader.ioMu.Unlock()
+
+	done := make(chan bool, 1)
+	go func() { done <- reader.IsConnected() }()
+
+	select {
+	case connected := <-done:
+		if !connected {
+			t.Fatal("expected TCP reader to report connected")
+		}
+	case <-time.After(100 * time.Millisecond):
+		t.Fatal("TCP connection status waited for the I/O lock")
+	}
+}
+
+func TestSerialReaderIsConnectedDoesNotWaitForIOLock(t *testing.T) {
+	reader := NewSerialReader("COM1", 115200)
+	reader.connected.Store(true)
+	reader.ioMu.Lock()
+	defer reader.ioMu.Unlock()
+
+	done := make(chan bool, 1)
+	go func() { done <- reader.IsConnected() }()
+
+	select {
+	case connected := <-done:
+		if !connected {
+			t.Fatal("expected serial reader to report connected")
+		}
+	case <-time.After(100 * time.Millisecond):
+		t.Fatal("serial connection status waited for the I/O lock")
+	}
+}

+ 125 - 28
internal/service/uhf/reader.go

@@ -112,15 +112,57 @@ type Manager struct {
 
 // DeviceHandler 每个设备的处理实例(包含读写器和协程)
 type DeviceHandler struct {
-	reader    Reader
-	device    *dao.UHFReader
-	ctx       context.Context
-	cancel    context.CancelFunc
-	dataChan  chan *ReportData
-	ioMu      sync.Mutex
-	stopOnce  sync.Once
-	done      chan struct{}
-	isRunning bool
+	reader     Reader
+	device     *dao.UHFReader
+	ctx        context.Context
+	cancel     context.CancelFunc
+	dataChan   chan *ReportData
+	ioMu       sync.Mutex
+	reportMu   sync.Mutex
+	lastReport map[string]time.Time
+	stopOnce   sync.Once
+	done       chan struct{}
+	isRunning  bool
+}
+
+const (
+	// reportDebounceWindow prevents readers that continuously report the same tag
+	// from filling the business queue and hitting SQLite for every radio frame.
+	reportDebounceWindow = 3 * time.Second
+	maxReportCacheSize   = 1024
+)
+
+// acceptReport returns false when the same device/EPC was accepted recently.
+// The check runs before the report enters the business queue, so duplicate
+// radio frames do not generate log, database, or passage-processing load.
+func (h *DeviceHandler) acceptReport(report *ReportData, now time.Time) bool {
+	if report == nil || len(report.Epcs) == 0 || report.Epcs[0] == "" {
+		return false
+	}
+
+	key := report.DeviceCode + "\x00" + report.Epcs[0]
+	h.reportMu.Lock()
+	defer h.reportMu.Unlock()
+
+	if last, ok := h.lastReport[key]; ok && now.Sub(last) < reportDebounceWindow {
+		return false
+	}
+	if h.lastReport == nil {
+		h.lastReport = make(map[string]time.Time)
+	}
+	h.lastReport[key] = now
+
+	// Bound memory for installations with many tags. Cleanup is opportunistic
+	// and only runs after the cache reaches the configured upper bound.
+	if len(h.lastReport) > maxReportCacheSize {
+		cutoff := now.Add(-reportDebounceWindow)
+		for cachedKey, cachedAt := range h.lastReport {
+			if cachedAt.Before(cutoff) {
+				delete(h.lastReport, cachedKey)
+			}
+		}
+	}
+	return true
 }
 
 // Register 注册设备
@@ -298,13 +340,14 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 	// 3. 创建上下文和handler
 	ctx, cancel := context.WithCancel(context.Background())
 	h := &DeviceHandler{
-		reader:    r,
-		device:    device,
-		ctx:       ctx,
-		cancel:    cancel,
-		dataChan:  make(chan *ReportData, 200),
-		done:      make(chan struct{}),
-		isRunning: true,
+		reader:     r,
+		device:     device,
+		ctx:        ctx,
+		cancel:     cancel,
+		dataChan:   make(chan *ReportData, 200),
+		lastReport: make(map[string]time.Time),
+		done:       make(chan struct{}),
+		isRunning:  true,
 	}
 
 	// 4. 注册到管理器
@@ -388,14 +431,23 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 					continue
 				}
 				if global.GVA_LOG != nil {
-					global.GVA_LOG.Info("收到 UHF/RFID 原始帧",
+					global.GVA_LOG.Debug("收到 UHF/RFID 原始帧",
 						zap.String("device_code", device.DeviceCode),
 						zap.Int("packet_bytes", len(buf)),
 						zap.String("packet_hex", hex.EncodeToString(buf)),
 					)
 				}
 
-				// 解析上报数据
+				// 解析上报数据(继电器指令应答等控制帧不走标签解析)
+				if !isTagReportFrame(buf) {
+					if global.GVA_LOG != nil {
+						global.GVA_LOG.Debug("UHF/RFID 忽略非标签帧",
+							zap.String("device_code", device.DeviceCode),
+							zap.String("packet_hex", hex.EncodeToString(buf)),
+						)
+					}
+					continue
+				}
 				report, err := parseReportData(device.DeviceCode, buf)
 				if err != nil {
 					if global.GVA_LOG != nil {
@@ -408,13 +460,16 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 					continue
 				}
 				if global.GVA_LOG != nil {
-					global.GVA_LOG.Info("UHF/RFID 标签解析成功",
+					global.GVA_LOG.Debug("UHF/RFID 标签解析成功",
 						zap.String("device_code", report.DeviceCode),
 						zap.Strings("epcs", report.Epcs),
 						zap.Int("rssi", report.RSSI),
 						zap.Int("antenna", report.Antenna),
 					)
 				}
+				if !h.acceptReport(report, time.Now()) {
+					continue
+				}
 
 				// 推送(不丢死,也不阻塞)
 				select {
@@ -463,6 +518,47 @@ func waitDeviceRetry(ctx context.Context, delay time.Duration) bool {
 	}
 }
 
+// extractUHFFrame 从缓冲区提取一帧校验通过的协议帧。
+// 帧结构: CF ADDR CMD_H CMD_L LEN PAYLOAD... CRC16_LO CRC16_HI,总长 7+LEN。
+// 标签上报帧(0x0001)固定25字节,继电器指令应答等控制帧长度可变(如 0x0077 应答为9字节)。
+// 只有 CRC 校验通过才认帧,否则跳过 1 字节重新同步,避免半帧/应答帧污染拼包缓冲区。
+func extractUHFFrame(buffer []byte) (frame []byte, rest []byte, ok bool) {
+	for {
+		if len(buffer) == 0 {
+			return nil, buffer, false
+		}
+		if buffer[0] != 0xCF {
+			buffer = buffer[1:]
+			continue
+		}
+		if len(buffer) < 7 {
+			return nil, buffer, false
+		}
+		total := 7 + int(buffer[4])
+		if total > 135 {
+			// LEN 超出协议合理范围,视为伪帧头
+			buffer = buffer[1:]
+			continue
+		}
+		if len(buffer) < total {
+			return nil, buffer, false
+		}
+		candidate := buffer[:total]
+		recvCRC := binary.LittleEndian.Uint16(candidate[total-2:])
+		calcCRC := uiCrc16Cal(candidate, uint8(total-2))
+		if recvCRC != calcCRC {
+			buffer = buffer[1:]
+			continue
+		}
+		return candidate, buffer[total:], true
+	}
+}
+
+// isTagReportFrame 判断是否为标签上报帧(命令字 0x0001,25字节)。
+func isTagReportFrame(frame []byte) bool {
+	return len(frame) == 25 && frame[2] == 0x00 && frame[3] == 0x01
+}
+
 // parseReportData 解析上报数据。
 func parseReportData(deviceCode string, buf []byte) (*ReportData, error) {
 	if len(buf) < 25 || buf[0] != 0xCF {
@@ -578,40 +674,41 @@ const (
 
 // CloseRelay1 开闸
 func (s *SerialReader) CloseRelay1(validTime byte) error {
-	frame := buildRelayFrame(0x0077, 1, RELAY_OP_CLOSE, validTime)
+	frame := buildRelayFrame(0x0077, RELAY_OP_CLOSE, validTime)
 	_, err := s.SendAndRecv(frame)
 	return err
 }
 
 // ReleaseRelay1
 func (s *SerialReader) ReleaseRelay1() error {
-	frame := buildRelayFrame(0x0077, 1, RELAY_OP_RELEASE, 0)
+	frame := buildRelayFrame(0x0077, RELAY_OP_RELEASE, 0)
 	_, err := s.SendAndRecv(frame)
 	return err
 }
 
 // CloseRelay2 关闸
 func (s *SerialReader) CloseRelay2(validTime byte) error {
-	frame := buildRelayFrame(0x0078, 2, RELAY_OP_CLOSE, validTime)
+	frame := buildRelayFrame(0x0078, RELAY_OP_CLOSE, validTime)
 	_, err := s.SendAndRecv(frame)
 	return err
 }
 
 // ReleaseRelay2
 func (s *SerialReader) ReleaseRelay2() error {
-	frame := buildRelayFrame(0x0078, 2, RELAY_OP_RELEASE, 0)
+	frame := buildRelayFrame(0x0078, RELAY_OP_RELEASE, 0)
 	_, err := s.SendAndRecv(frame)
 	return err
 }
 
-// buildRelayFrame 构建指令
-func buildRelayFrame(cmd uint16, relayNum byte, option byte, validTime byte) []byte {
+// buildRelayFrame 构建继电器控制指令(手册 2.2.14 / 2.2.15)。
+// 继电器号由命令字区分:0x0077=继电器1,0x0078=继电器2;
+// LEN 固定为 2,PAYLOAD = [Option, ValidTime],不含继电器号字节。
+func buildRelayFrame(cmd uint16, option byte, validTime byte) []byte {
 	frame := []byte{
 		0xCF, 0xFF,
 		byte(cmd >> 8), byte(cmd & 0xFF),
-		0x03,     // len
-		relayNum, // 1=继电器1  2=继电器2
-		option,
+		0x02,     // len: Option + ValidTime
+		option,   // 0x01 释放 / 0x02 闭合
 		validTime,
 	}
 	crc := uiCrc16Cal(frame, uint8(len(frame)))

+ 32 - 0
internal/service/uhf/reader_event_test.go

@@ -2,6 +2,7 @@ package uhf
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -32,3 +33,34 @@ func TestChannelEventsUseMonotonicIDs(t *testing.T) {
 	require.Len(t, afterFirst, 1)
 	require.Equal(t, "B", afterFirst[0].RFIDTag)
 }
+
+func TestDeviceHandlerAcceptReportDeduplicatesDeviceAndEPC(t *testing.T) {
+	h := &DeviceHandler{lastReport: make(map[string]time.Time)}
+	base := time.Unix(100, 0)
+	report := &ReportData{DeviceCode: "RFID-01", Epcs: []string{"e20001"}}
+
+	if !h.acceptReport(report, base) {
+		t.Fatal("first report should be accepted")
+	}
+	if h.acceptReport(report, base.Add(reportDebounceWindow-time.Nanosecond)) {
+		t.Fatal("duplicate report inside debounce window should be rejected")
+	}
+	if !h.acceptReport(report, base.Add(reportDebounceWindow)) {
+		t.Fatal("report at the end of debounce window should be accepted")
+	}
+}
+
+func TestDeviceHandlerAcceptReportAllowsDifferentEPCs(t *testing.T) {
+	h := &DeviceHandler{lastReport: make(map[string]time.Time)}
+	now := time.Unix(200, 0)
+
+	if !h.acceptReport(&ReportData{DeviceCode: "RFID-01", Epcs: []string{"e20001"}}, now) {
+		t.Fatal("first EPC should be accepted")
+	}
+	if !h.acceptReport(&ReportData{DeviceCode: "RFID-01", Epcs: []string{"e20002"}}, now) {
+		t.Fatal("different EPC should be accepted")
+	}
+	if !h.acceptReport(&ReportData{DeviceCode: "RFID-02", Epcs: []string{"e20001"}}, now) {
+		t.Fatal("same EPC from a different device should be accepted")
+	}
+}

+ 24 - 32
internal/service/uhf/serial_reader.go

@@ -6,16 +6,17 @@ import (
 	"io"
 	"os"
 	"sync"
+	"sync/atomic"
 	"time"
 )
 
 type SerialReader struct {
-	COMPort     string
-	BaudRate    int
-	port        *serial.Port
-	isConnected bool
-	buffer      []byte // 拼包缓冲区
-	ioMu        sync.Mutex
+	COMPort   string
+	BaudRate  int
+	port      *serial.Port
+	connected atomic.Bool
+	buffer    []byte // 拼包缓冲区
+	ioMu      sync.Mutex
 }
 
 func NewSerialReader(comPort string, baudRate int) *SerialReader {
@@ -38,11 +39,11 @@ func (s *SerialReader) Connect() error {
 	}
 	port, err := serial.OpenPort(cfg)
 	if err != nil {
-		s.isConnected = false
+		s.connected.Store(false)
 		return err
 	}
 	s.port = port
-	s.isConnected = true
+	s.connected.Store(true)
 	return nil
 }
 
@@ -52,15 +53,13 @@ func (s *SerialReader) Disconnect() error {
 	if s.port != nil {
 		_ = s.port.Close()
 	}
-	s.isConnected = false
+	s.connected.Store(false)
 	s.buffer = nil
 	return nil
 }
 
 func (s *SerialReader) IsConnected() bool {
-	s.ioMu.Lock()
-	defer s.ioMu.Unlock()
-	return s.isConnected
+	return s.connected.Load()
 }
 
 func (s *SerialReader) SendData(data []byte) error {
@@ -70,7 +69,7 @@ func (s *SerialReader) SendData(data []byte) error {
 }
 
 func (s *SerialReader) sendDataLocked(data []byte) error {
-	if !s.isConnected {
+	if !s.connected.Load() {
 		return errors.New("串口未连接")
 	}
 	n, err := s.port.Write(data)
@@ -84,7 +83,7 @@ func (s *SerialReader) sendDataLocked(data []byte) error {
 }
 
 // ==============================
-// 🔥 关键修复:串口返回完整25字节帧
+// 🔥 关键修复:串口按协议变长帧提取(标签帧25字节,继电器应答9字节等)
 // ==============================
 func (s *SerialReader) ReadData() ([]byte, error) {
 	s.ioMu.Lock()
@@ -93,7 +92,7 @@ func (s *SerialReader) ReadData() ([]byte, error) {
 }
 
 func (s *SerialReader) readDataLocked() ([]byte, error) {
-	if !s.isConnected {
+	if !s.connected.Load() {
 		return nil, errors.New("串口未连接")
 	}
 
@@ -104,30 +103,23 @@ func (s *SerialReader) readDataLocked() ([]byte, error) {
 			if err == io.EOF || os.IsTimeout(err) {
 				break
 			}
-			s.isConnected = false
+			s.connected.Store(false)
 			return nil, err
 		}
-		if n > 0 {
-			s.buffer = append(s.buffer, buf[:n]...)
-		}
-
-		// 找25字节完整帧
-		for len(s.buffer) >= 25 {
-			if s.buffer[0] == 0xCF {
-				frame := s.buffer[:25]
-				s.buffer = s.buffer[25:]
-				return frame, nil
-			} else {
-				s.buffer = s.buffer[1:]
-			}
+		if n == 0 {
+			break
 		}
+		s.buffer = append(s.buffer, buf[:n]...)
 
-		// 超时退出
-		if len(s.buffer) == 0 {
-			break
+		if frame, rest, ok := extractUHFFrame(s.buffer); ok {
+			s.buffer = rest
+			return frame, nil
 		}
 	}
 
+	if len(s.buffer) > 0 {
+		return nil, ErrIncompleteFrame
+	}
 	return nil, ErrReadTimeout
 }
 

+ 72 - 40
internal/service/uhf/tcp_reader.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"go.uber.org/zap"
@@ -12,32 +13,32 @@ import (
 )
 
 type TCPReader struct {
-	IP          string
-	Port        int
-	conn        net.Conn
-	isConnected bool
-	buffer      []byte // 内部拼包缓冲区(关键修复)
-	ioMu        sync.Mutex
+	IP        string
+	Port      int
+	conn      net.Conn
+	connected atomic.Bool
+	buffer    []byte // 内部拼包缓冲区(关键修复)
+	ioMu      sync.Mutex
 }
 
 // 实现 Reader 接口的继电器方法(不再panic)
 func (t *TCPReader) CloseRelay1(validTime byte) error {
-	frame := buildRelayFrame(0x0077, 1, RELAY_OP_CLOSE, validTime)
+	frame := buildRelayFrame(0x0077, RELAY_OP_CLOSE, validTime)
 	return t.sendRelayCommand("open", 1, validTime, frame)
 }
 
 func (t *TCPReader) ReleaseRelay1() error {
-	frame := buildRelayFrame(0x0077, 1, RELAY_OP_RELEASE, 0)
+	frame := buildRelayFrame(0x0077, RELAY_OP_RELEASE, 0)
 	return t.sendRelayCommand("release", 1, 0, frame)
 }
 
 func (t *TCPReader) CloseRelay2(validTime byte) error {
-	frame := buildRelayFrame(0x0078, 2, RELAY_OP_CLOSE, validTime)
+	frame := buildRelayFrame(0x0078, RELAY_OP_CLOSE, validTime)
 	return t.sendRelayCommand("close", 2, validTime, frame)
 }
 
 func (t *TCPReader) ReleaseRelay2() error {
-	frame := buildRelayFrame(0x0078, 2, RELAY_OP_RELEASE, 0)
+	frame := buildRelayFrame(0x0078, RELAY_OP_RELEASE, 0)
 	return t.sendRelayCommand("release", 2, 0, frame)
 }
 
@@ -54,12 +55,12 @@ func (t *TCPReader) Connect() error {
 	addr := net.JoinHostPort(t.IP, fmt.Sprintf("%d", t.Port))
 	conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
 	if err != nil {
-		t.isConnected = false
+		t.connected.Store(false)
 		t.logWarn("UHF TCP 连接失败", zap.Error(err))
 		return err
 	}
 	t.conn = conn
-	t.isConnected = true
+	t.connected.Store(true)
 	t.logInfo("UHF TCP 连接成功")
 	return nil
 }
@@ -70,16 +71,14 @@ func (t *TCPReader) Disconnect() error {
 	if t.conn != nil {
 		_ = t.conn.Close()
 	}
-	t.isConnected = false
+	t.connected.Store(false)
 	t.buffer = nil
 	t.logInfo("UHF TCP 连接已关闭")
 	return nil
 }
 
 func (t *TCPReader) IsConnected() bool {
-	t.ioMu.Lock()
-	defer t.ioMu.Unlock()
-	return t.isConnected
+	return t.connected.Load()
 }
 
 func (t *TCPReader) SendData(data []byte) error {
@@ -89,19 +88,19 @@ func (t *TCPReader) SendData(data []byte) error {
 }
 
 func (t *TCPReader) sendDataLocked(data []byte) error {
-	if !t.isConnected {
+	if !t.connected.Load() {
 		return errors.New("TCP未连接")
 	}
 	_, err := t.conn.Write(data)
 	if err != nil {
-		t.isConnected = false
+		t.connected.Store(false)
 		t.logWarn("UHF TCP 写入失败", zap.String("packet_hex", fmt.Sprintf("%x", data)), zap.Error(err))
 	}
 	return err
 }
 
 // ==============================
-// 🔥 关键修复:TCP 自动拼包,返回完整25字节帧
+// 🔥 关键修复:TCP 自动拼包,按协议变长帧提取(标签帧25字节,继电器应答9字节等)
 // ==============================
 func (t *TCPReader) ReadData() ([]byte, error) {
 	t.ioMu.Lock()
@@ -110,7 +109,7 @@ func (t *TCPReader) ReadData() ([]byte, error) {
 }
 
 func (t *TCPReader) readDataLocked() ([]byte, error) {
-	if !t.isConnected {
+	if !t.connected.Load() {
 		return nil, errors.New("TCP未连接")
 	}
 
@@ -123,31 +122,28 @@ func (t *TCPReader) readDataLocked() ([]byte, error) {
 		if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
 			return nil, ErrReadTimeout
 		}
-		t.isConnected = false
+		t.connected.Store(false)
 		t.logWarn("UHF TCP 读取失败", zap.Error(err))
 		return nil, err
 	}
 
 	// 加入缓冲区
 	t.buffer = append(t.buffer, buf[:n]...)
-	t.logInfo("UHF TCP 收到数据块",
+	t.logDebug("UHF TCP 收到数据块",
 		zap.Int("packet_bytes", n),
 		zap.Int("buffered_bytes", len(t.buffer)),
 		zap.String("packet_hex", fmt.Sprintf("%x", buf[:n])),
 	)
 
-	// 找完整帧:0xCF 开头 + 25字节
-	for len(t.buffer) >= 25 {
-		if t.buffer[0] == 0xCF {
-			frame := t.buffer[:25]
-			t.buffer = t.buffer[25:]
-			return frame, nil
-		} else {
-			t.buffer = t.buffer[1:]
+	frame, rest, ok := extractUHFFrame(t.buffer)
+	t.buffer = rest
+	if !ok {
+		if len(t.buffer) > 0 {
+			return nil, ErrIncompleteFrame
 		}
+		return nil, ErrReadTimeout
 	}
-
-	return nil, ErrIncompleteFrame
+	return frame, nil
 }
 
 func (t *TCPReader) SendAndRecv(data []byte) ([]byte, error) {
@@ -160,6 +156,12 @@ func (t *TCPReader) SendAndRecv(data []byte) ([]byte, error) {
 	return t.readDataLocked()
 }
 
+// isRelayAckFor 判断响应帧是否为对应指令的应答(设备回显命令字)。
+func isRelayAckFor(response, frame []byte) bool {
+	return len(response) >= 7 && response[0] == 0xCF &&
+		response[2] == frame[2] && response[3] == frame[3]
+}
+
 func (t *TCPReader) sendRelayCommand(action string, relay int, validTime byte, frame []byte) error {
 	t.logInfo("UHF TCP 道闸指令下发",
 		zap.String("action", action),
@@ -167,17 +169,39 @@ func (t *TCPReader) sendRelayCommand(action string, relay int, validTime byte, f
 		zap.Uint8("valid_time_seconds", validTime),
 		zap.String("packet_hex", fmt.Sprintf("%x", frame)),
 	)
-	response, err := t.SendAndRecv(frame)
-	if err != nil {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
+	if err := t.sendDataLocked(frame); err != nil {
 		t.logWarn("UHF TCP 道闸指令失败", zap.String("action", action), zap.Error(err))
 		return err
 	}
-	t.logInfo("UHF TCP 道闸指令收到响应",
-		zap.String("action", action),
-		zap.Int("response_bytes", len(response)),
-		zap.String("response_hex", fmt.Sprintf("%x", response)),
-	)
-	return nil
+	// 等待设备应答(回显命令字)。期间收到的标签上报帧放回缓冲区交还读循环。
+	for attempt := 0; attempt < 3; attempt++ {
+		response, err := t.readDataLocked()
+		if err != nil {
+			break
+		}
+		if isRelayAckFor(response, frame) {
+			status := "unknown"
+			if len(response) >= 7 {
+				status = fmt.Sprintf("0x%02x", response[5])
+			}
+			t.logInfo("UHF TCP 道闸指令收到响应",
+				zap.String("action", action),
+				zap.Int("relay", relay),
+				zap.String("status", status),
+				zap.String("response_hex", fmt.Sprintf("%x", response)),
+			)
+			if len(response) >= 7 && response[5] != 0x00 {
+				return fmt.Errorf("道闸指令被设备拒绝: 状态码 %s", status)
+			}
+			return nil
+		}
+		t.buffer = append(append(make([]byte, 0, len(response)+len(t.buffer)), response...), t.buffer...)
+	}
+	err := fmt.Errorf("道闸指令未收到设备应答(帧已发出)")
+	t.logWarn("UHF TCP 道闸指令失败", zap.String("action", action), zap.Error(err))
+	return err
 }
 
 func (t *TCPReader) logInfo(message string, fields ...zap.Field) {
@@ -188,6 +212,14 @@ func (t *TCPReader) logInfo(message string, fields ...zap.Field) {
 	global.GVA_LOG.Info(message, fields...)
 }
 
+func (t *TCPReader) logDebug(message string, fields ...zap.Field) {
+	if global.GVA_LOG == nil {
+		return
+	}
+	fields = append([]zap.Field{zap.String("tcp_address", net.JoinHostPort(t.IP, fmt.Sprintf("%d", t.Port)))}, fields...)
+	global.GVA_LOG.Debug(message, fields...)
+}
+
 func (t *TCPReader) logWarn(message string, fields ...zap.Field) {
 	if global.GVA_LOG == nil {
 		return