Quellcode durchsuchen

fix(nextion): resolve thread-safety issues and expand tests

wenhongquan vor 2 Monaten
Ursprung
Commit
8cdb9c7f7d
2 geänderte Dateien mit 252 neuen und 54 gelöschten Zeilen
  1. 84 44
      backend/modules/nextion_display.py
  2. 168 10
      backend/tests/test_nextion_display.py

+ 84 - 44
backend/modules/nextion_display.py

@@ -10,8 +10,11 @@ import serial
 
 logger = logging.getLogger('nextion_display')
 
+EVENT_HEADER = 0x65
+TERMINATOR = b'\xFF\xFF\xFF'
 
-def parse_nextion_events(data: bytes):
+
+def parse_nextion_events(data: bytes) -> tuple[list[dict], bytes]:
     """从字节流中解析 Nextion 触控事件(0x65 事件头,7 字节一包)。
 
     返回 (events, remaining_bytes),其中 remaining_bytes 是未消费的字节,
@@ -21,13 +24,13 @@ def parse_nextion_events(data: bytes):
     i = 0
     remaining = b''
     while i < len(data):
-        idx = data.find(0x65, i)
+        idx = data.find(EVENT_HEADER, i)
         if idx < 0:
             break
         if idx + 7 > len(data):
             remaining = data[idx:]
             break
-        if data[idx + 4] == 0xFF and data[idx + 5] == 0xFF and data[idx + 6] == 0xFF:
+        if data[idx + 4:idx + 7] == TERMINATOR:
             events.append({
                 'type': 'button',
                 'page': data[idx + 1],
@@ -45,7 +48,7 @@ class NextionDisplay:
 
     def __init__(self):
         self.ser = None
-        self.lock = threading.Lock()
+        self.lock = threading.RLock()
         self.read_thread = None
         self.stop_event = threading.Event()
         self.button_callback = None
@@ -54,43 +57,72 @@ class NextionDisplay:
         self.connected = False
 
     def connect(self, port: str, baudrate: int = 9600, timeout: float = 0.1):
-        try:
-            with self.lock:
-                if self.connected:
+        """连接 Nextion 屏幕串口。
+
+        如果已经连接,会先断开当前连接。
+
+        Args:
+            port: 串口设备路径。
+            baudrate: 波特率,默认 9600。
+            timeout: 串口读取超时时间,默认 0.1 秒。
+
+        Returns:
+            tuple[bool, str]: 成功时返回 (True, 描述信息),失败时返回 (False, 错误信息)。
+        """
+        with self.lock:
+            if self.connected:
+                # 释放锁后再调用 disconnect,避免在同一把锁上产生死锁。
+                self.lock.release()
+                try:
                     self.disconnect()
-                logger.info(f"连接 Nextion 屏幕串口: {port} @ {baudrate}")
-                self.ser = serial.Serial(port, baudrate, timeout=timeout)
-                self.port = port
-                self.baudrate = baudrate
-                self.connected = True
-                self.stop_event.clear()
-                self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
-                self.read_thread.start()
-                logger.info(f"Nextion 屏幕串口已连接: {port}")
-                return True, f"已连接 {port} @ {baudrate}"
+                finally:
+                    self.lock.acquire()
+
+        try:
+            logger.info(f"连接 Nextion 屏幕串口: {port} @ {baudrate}")
+            ser = serial.Serial(port, baudrate, timeout=timeout)
         except Exception as e:
-            self.connected = False
-            self.ser = None
+            with self.lock:
+                self.connected = False
+                self.ser = None
             msg = f"连接 Nextion 屏幕串口失败: {e}"
             logger.error(msg)
             return False, msg
 
+        with self.lock:
+            self.ser = ser
+            self.port = port
+            self.baudrate = baudrate
+            self.connected = True
+            self.stop_event.clear()
+            self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
+            self.read_thread.start()
+            logger.info(f"Nextion 屏幕串口已连接: {port}")
+
+        return True, f"已连接 {port} @ {baudrate}"
+
     def disconnect(self):
         try:
             with self.lock:
+                was_connected = self.connected
                 self.stop_event.set()
-                if self.read_thread and self.read_thread.is_alive():
-                    self.read_thread.join(timeout=2.0)
-                if self.ser and self.ser.is_open:
-                    try:
-                        self.ser.close()
-                    except Exception as e:
-                        logger.error(f"关闭 Nextion 串口失败: {e}")
+                ser = self.ser
                 self.ser = None
                 self.connected = False
                 self.port = None
                 self.baudrate = None
-            logger.info("Nextion 屏幕串口已断开")
+
+            if self.read_thread and self.read_thread.is_alive():
+                self.read_thread.join(timeout=2.0)
+
+            if ser and ser.is_open:
+                try:
+                    ser.close()
+                except Exception as e:
+                    logger.error(f"关闭 Nextion 串口失败: {e}")
+
+            if was_connected:
+                logger.info("Nextion 屏幕串口已断开")
             return True, "已断开"
         except Exception as e:
             logger.error(f"断开 Nextion 屏幕串口失败: {e}")
@@ -101,18 +133,22 @@ class NextionDisplay:
         buf = bytearray()
         while not self.stop_event.is_set():
             try:
-                if self.ser and self.ser.is_open and self.ser.in_waiting > 0:
-                    buf.extend(self.ser.read(self.ser.in_waiting))
+                with self.lock:
+                    ser = self.ser
+                if ser and ser.is_open and ser.in_waiting > 0:
+                    buf.extend(ser.read(ser.in_waiting))
                     events, remaining = parse_nextion_events(bytes(buf))
                     buf.clear()
                     buf.extend(remaining)
                     for evt in events:
-                            logger.debug(f"Nextion 事件: {evt}")
-                            if self.button_callback:
-                                try:
-                                    self.button_callback(evt['page'], evt['comp'], evt['event'])
-                                except Exception as e:
-                                    logger.error(f"按钮回调异常: {e}")
+                        logger.debug(f"Nextion 事件: {evt}")
+                        with self.lock:
+                            callback = self.button_callback
+                        if callback:
+                            try:
+                                callback(evt['page'], evt['comp'], evt['event'])
+                            except Exception as e:
+                                logger.error(f"按钮回调异常: {e}")
                 else:
                     time.sleep(0.01)
             except Exception as e:
@@ -121,7 +157,7 @@ class NextionDisplay:
         logger.info("Nextion 事件监听线程结束")
 
     def _encode_cmd(self, cmd: str) -> bytes:
-        return cmd.encode('gb2312', errors='replace') + b'\xFF\xFF\xFF'
+        return cmd.encode('gb2312', errors='replace') + TERMINATOR
 
     def send_cmd(self, cmd: str, delay_ms: float = 0) -> bool:
         try:
@@ -138,7 +174,8 @@ class NextionDisplay:
             return True
         except Exception as e:
             logger.error(f"发送 Nextion 指令失败 '{cmd}': {e}")
-            self.connected = False
+            with self.lock:
+                self.connected = False
             return False
 
     def send_raw(self, data: bytes) -> bool:
@@ -153,18 +190,21 @@ class NextionDisplay:
             return True
         except Exception as e:
             logger.error(f"发送 Nextion 原始数据失败: {e}")
-            self.connected = False
+            with self.lock:
+                self.connected = False
             return False
 
     def set_button_callback(self, callback):
-        self.button_callback = callback
+        with self.lock:
+            self.button_callback = callback
 
     def get_status(self):
-        return {
-            'connected': self.connected and self.ser is not None and self.ser.is_open,
-            'port': self.port,
-            'baudrate': self.baudrate,
-        }
+        with self.lock:
+            return {
+                'connected': self.connected and self.ser is not None and self.ser.is_open,
+                'port': self.port,
+                'baudrate': self.baudrate,
+            }
 
 
 if __name__ == '__main__':

+ 168 - 10
backend/tests/test_nextion_display.py

@@ -1,15 +1,23 @@
-import pytest
-import sys
 import os
+import sys
+import time
+from unittest.mock import MagicMock, Mock, PropertyMock, patch
 
-# 添加项目根目录到Python路径
+import pytest
+
+# 添加项目根目录到 Python 路径
 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
-from modules.nextion_display import NextionDisplay, parse_nextion_events
+from modules.nextion_display import EVENT_HEADER, TERMINATOR, NextionDisplay, parse_nextion_events
+
+
+@pytest.fixture
+def display():
+    return NextionDisplay()
 
 
 def test_parse_button_event():
-    data = bytes([0x65, 0x00, 0x05, 0x01, 0xFF, 0xFF, 0xFF])
+    data = bytes([EVENT_HEADER, 0x00, 0x05, 0x01]) + TERMINATOR
     events, remaining = parse_nextion_events(data)
     assert events == [{'type': 'button', 'page': 0, 'comp': 5, 'event': 1}]
     assert remaining == b''
@@ -17,8 +25,8 @@ def test_parse_button_event():
 
 def test_parse_multiple_events():
     data = (
-        bytes([0x65, 0x00, 0x05, 0x01, 0xFF, 0xFF, 0xFF]) +
-        bytes([0x65, 0x00, 0x06, 0x01, 0xFF, 0xFF, 0xFF])
+        bytes([EVENT_HEADER, 0x00, 0x05, 0x01]) + TERMINATOR +
+        bytes([EVENT_HEADER, 0x00, 0x06, 0x01]) + TERMINATOR
     )
     events, remaining = parse_nextion_events(data)
     assert len(events) == 2
@@ -27,14 +35,164 @@ def test_parse_multiple_events():
 
 
 def test_parse_event_with_trailing_partial_bytes():
-    data = bytes([0x65, 0x00, 0x05, 0x01, 0xFF, 0xFF, 0xFF, 0x65])
+    data = bytes([EVENT_HEADER, 0x00, 0x05, 0x01]) + TERMINATOR + bytes([EVENT_HEADER])
     events, remaining = parse_nextion_events(data)
     assert events == [{'type': 'button', 'page': 0, 'comp': 5, 'event': 1}]
-    assert remaining == bytes([0x65])
+    assert remaining == bytes([EVENT_HEADER])
 
 
 def test_encode_nextion_cmd():
     display = NextionDisplay()
     encoded = display._encode_cmd('t2.txt="终端1"')
-    assert encoded.endswith(b'\xFF\xFF\xFF')
+    assert encoded.endswith(TERMINATOR)
     assert encoded.startswith(b't2.txt=')
+
+
+def test_connect_success(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial) as mock_cls:
+        ok, msg = display.connect('/dev/ttyUSB0', 115200)
+
+    assert ok is True
+    assert '115200' in msg
+    mock_cls.assert_called_once_with('/dev/ttyUSB0', 115200, timeout=0.1)
+    assert display.connected is True
+    assert display.port == '/dev/ttyUSB0'
+    display.disconnect()
+
+
+def test_connect_failure(display):
+    with patch('modules.nextion_display.serial.Serial', side_effect=Exception("open fail")):
+        ok, msg = display.connect('/dev/ttyUSB0', 9600)
+
+    assert ok is False
+    assert 'open fail' in msg
+    assert display.connected is False
+    assert display.ser is None
+
+
+def test_disconnect_when_connected(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+
+    ok, msg = display.disconnect()
+    assert ok is True
+    assert msg == "已断开"
+    assert display.connected is False
+    assert display.ser is None
+    assert display.port is None
+    assert display.baudrate is None
+
+
+def test_disconnect_when_not_connected(display):
+    ok, msg = display.disconnect()
+    assert ok is True
+    assert msg == "已断开"
+    assert display.connected is False
+
+
+def test_send_cmd_success(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+
+    ok = display.send_cmd('t2.txt="test"')
+    assert ok is True
+    mock_serial.write.assert_called()
+    display.disconnect()
+
+
+def test_send_cmd_failure_not_connected(display):
+    ok = display.send_cmd('t2.txt="test"')
+    assert ok is False
+
+
+def test_send_cmd_failure_exception(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+    mock_serial.write.side_effect = Exception("write fail")
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+
+    ok = display.send_cmd('t2.txt="test"')
+    assert ok is False
+    assert display.connected is False
+    display.disconnect()
+
+
+def test_send_raw_success(display):
+    raw = b'\x00\x01\x02' + TERMINATOR
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+
+    ok = display.send_raw(raw)
+    assert ok is True
+    mock_serial.write.assert_called_with(raw)
+    display.disconnect()
+
+
+def test_get_status(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 115200)
+
+    status = display.get_status()
+    assert status['connected'] is True
+    assert status['port'] == '/dev/ttyUSB0'
+    assert status['baudrate'] == 115200
+    display.disconnect()
+
+
+def test_read_loop_dispatches_button_callback(display):
+    callback = Mock()
+    display.set_button_callback(callback)
+
+    event_bytes = bytes([EVENT_HEADER, 0x00, 0x05, 0x01]) + TERMINATOR
+
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    type(mock_serial).in_waiting = PropertyMock(
+        side_effect=[len(event_bytes)] + [0] * 100
+    )
+    mock_serial.read.return_value = event_bytes
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+
+    time.sleep(0.05)
+    display.disconnect()
+    callback.assert_called_once_with(0, 5, 1)
+
+
+def test_reentrant_connect_no_deadlock(display):
+    mock_serial = MagicMock()
+    mock_serial.is_open = True
+    mock_serial.in_waiting = 0
+
+    with patch('modules.nextion_display.serial.Serial', return_value=mock_serial):
+        display.connect('/dev/ttyUSB0', 9600)
+        display.connect('/dev/ttyUSB1', 115200)
+
+    assert display.port == '/dev/ttyUSB1'
+    assert display.baudrate == 115200
+    assert display.connected is True
+    display.disconnect()