For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在现有 dzxj_dtu 后端接入 /dev/ttyS9 上的 Nextion 串口屏,实时显示 DTU 管理的 panel 端口状态。
Architecture: 新增独立 NextionDisplay 模块管理屏幕串口与协议;在 app.py 中维护 panel 到“终端”的映射,Modbus 轮询和屏上按钮事件触发屏幕刷新;通过独立串口与现有 RS485 通信隔离。
Tech Stack: Python 3, Flask, Flask-SocketIO, pyserial
backend/modules/nextion_display.py:Nextion 协议封装(串口连接、指令发送、按钮事件监听)。backend/config.py:新增 Nextion 相关配置项。backend/app.py:初始化屏幕、集成刷新逻辑、新增 API、修正 screen_connected 状态上报。backend/tests/test_nextion_display.py:NextionDisplay 核心功能单元测试(事件解析、指令编码)。Files:
backend/config.py上下文: 在 backend/config.py 末尾 DHT11 配置之后追加 Nextion 配置。
backend/config.py 追加配置在文件末尾追加:
# Nextion 串口屏配置(接 /dev/ttyS9)
NEXTION_ENABLED = os.getenv('NEXTION_ENABLED', 'true').lower() in ('1', 'true', 'yes')
NEXTION_SCREEN_PORT = os.getenv('NEXTION_SCREEN_PORT', '/dev/ttyS9')
NEXTION_SCREEN_BAUD = int(os.getenv('NEXTION_SCREEN_BAUD', '9600'))
NEXTION_PANEL_NAME_PREFIX = os.getenv('NEXTION_PANEL_NAME_PREFIX', '终端')
NEXTION_CMD_DELAY_MS = int(os.getenv('NEXTION_CMD_DELAY_MS', '25')) # 连续指令间隔,避免缓冲溢出
Run:
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
python -c "import config; print(config.NEXTION_SCREEN_PORT, config.NEXTION_SCREEN_BAUD)"
Expected: 输出 /dev/ttyS9 9600
[ ] Step 3: Commit
git add backend/config.py
git commit -m "config: add Nextion display settings"
Files:
backend/modules/nextion_display.pybackend/tests/test_nextion_display.py[ ] 创建 backend/tests/test_nextion_display.py:
import pytest
from modules.nextion_display import NextionDisplay, parse_nextion_events
def test_parse_button_event():
data = bytes([0x65, 0x00, 0x05, 0x01, 0xFF, 0xFF, 0xFF])
events = parse_nextion_events(data)
assert events == [{'type': 'button', 'page': 0, 'comp': 5, 'event': 1}]
def test_parse_multiple_events():
data = (
bytes([0x65, 0x00, 0x05, 0x01, 0xFF, 0xFF, 0xFF]) +
bytes([0x65, 0x00, 0x06, 0x01, 0xFF, 0xFF, 0xFF])
)
events = parse_nextion_events(data)
assert len(events) == 2
assert events[1] == {'type': 'button', 'page': 0, 'comp': 6, 'event': 1}
def test_encode_nextion_cmd():
display = NextionDisplay()
encoded = display._encode_cmd('t2.txt="终端1"')
assert encoded.endswith(b'\xFF\xFF\xFF')
assert b't2.txt="\xd6\xd5\xb6\xcb1"' in encoded or encoded.startswith(b't2.txt=')
[ ] Step 2: 运行测试确认失败
Run:
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
pytest tests/test_nextion_display.py -v
Expected: ModuleNotFoundError 或 ImportError
[ ] 创建 backend/modules/nextion_display.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Nextion 串口屏协议封装。
参考 nextion_web_control.py 实现,提供:
- 串口连接/断开
- Nextion 指令发送(GB2312 + 0xFF 0xFF 0xFF 结尾)
- 原始字节发送
- 触控事件监听(0x65 事件头)
"""
import logging
import threading
import time
import serial
logger = logging.getLogger('nextion_display')
def parse_nextion_events(data: bytes):
"""从字节流中解析 Nextion 触控事件(0x65 事件头,7 字节一包)。"""
events = []
buf = bytearray(data)
while len(buf) >= 7:
idx = -1
for i in range(len(buf)):
if buf[i] == 0x65:
idx = i
break
if idx < 0:
break
if idx > 0:
del buf[:idx]
if len(buf) < 7:
break
if buf[4] == 0xFF and buf[5] == 0xFF and buf[6] == 0xFF:
events.append({
'type': 'button',
'page': buf[1],
'comp': buf[2],
'event': buf[3],
})
del buf[:7]
else:
del buf[:1]
return events
class NextionDisplay:
"""Nextion 串口屏管理类。"""
def __init__(self):
self.ser = None
self.lock = threading.Lock()
self.read_thread = None
self.stop_event = threading.Event()
self.button_callback = None
self.port = None
self.baudrate = None
self.connected = False
def connect(self, port: str, baudrate: int = 9600, timeout: float = 0.1):
"""连接屏幕串口。"""
try:
with self.lock:
if self.connected:
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}"
except Exception as e:
self.connected = False
self.ser = None
msg = f"连接 Nextion 屏幕串口失败: {e}"
logger.error(msg)
return False, msg
def disconnect(self):
"""断开屏幕串口。"""
try:
with self.lock:
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}")
self.ser = None
self.connected = False
self.port = None
self.baudrate = None
logger.info("Nextion 屏幕串口已断开")
return True, "已断开"
except Exception as e:
logger.error(f"断开 Nextion 屏幕串口失败: {e}")
return False, str(e)
def _read_loop(self):
"""后台读取屏幕返回事件。"""
logger.info("启动 Nextion 事件监听线程")
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))
events = parse_nextion_events(bytes(buf))
if events:
buf.clear()
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}")
else:
time.sleep(0.01)
except Exception as e:
logger.error(f"Nextion 读取线程异常: {e}")
time.sleep(0.5)
logger.info("Nextion 事件监听线程结束")
def _encode_cmd(self, cmd: str) -> bytes:
"""将字符串指令编码为 Nextion 协议数据包。"""
return cmd.encode('gb2312', errors='replace') + b'\xFF\xFF\xFF'
def send_cmd(self, cmd: str, delay_ms: float = 0) -> bool:
"""发送一条 Nextion 指令。"""
try:
with self.lock:
if not self.connected or not self.ser or not self.ser.is_open:
logger.warning(f"Nextion 未连接,无法发送: {cmd}")
return False
data = self._encode_cmd(cmd)
self.ser.write(data)
self.ser.flush()
logger.debug(f"[Nextion TX] {cmd}")
if delay_ms > 0:
time.sleep(delay_ms / 1000.0)
return True
except Exception as e:
logger.error(f"发送 Nextion 指令失败 '{cmd}': {e}")
self.connected = False
return False
def send_raw(self, data: bytes) -> bool:
"""发送原始字节。"""
try:
with self.lock:
if not self.connected or not self.ser or not self.ser.is_open:
logger.warning("Nextion 未连接,无法发送原始数据")
return False
self.ser.write(data)
self.ser.flush()
logger.debug(f"[Nextion TX RAW] {data.hex()}")
return True
except Exception as e:
logger.error(f"发送 Nextion 原始数据失败: {e}")
self.connected = False
return False
def set_button_callback(self, callback):
"""设置按钮事件回调:callback(page_id, comp_id, event)。"""
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,
}
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
disp = NextionDisplay()
ok, msg = disp.connect('/dev/ttyS9', 9600)
print(ok, msg)
if ok:
disp.send_cmd('t2.txt="测试"')
time.sleep(1)
disp.disconnect()
[ ] Step 4: 运行测试确认通过
Run:
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
pytest tests/test_nextion_display.py -v
Expected: 3 个测试全部 PASS
[ ] Step 5: Commit
git add backend/modules/nextion_display.py backend/tests/test_nextion_display.py
git commit -m "feat(nextion): add NextionDisplay module with event parsing tests"
app.py 中集成 Nextion 显示Files:
backend/app.py[ ] 在 backend/app.py 中,找到 from modules.dht11_sensor import DHT11Sensor 这一行,在其下方新增:
from modules.nextion_display import NextionDisplay
[ ] 在 serial_client = SerialPort() 附近新增全局实例:
# Nextion 串口屏实例
screen_display = NextionDisplay()
screen_current_panel_index = 0
[ ] 在 app.py 中合适位置(建议在 port_state 定义之后)新增以下函数:
def get_sorted_panels():
"""按 position 排序返回 panel 列表。"""
return sorted(panel_config.items(), key=lambda x: x[1].get('position', 0))
def _port_status_to_pic(port_state_entry):
"""将端口状态推导为 Nextion pic 编号。"""
if not port_state_entry:
return 0
last_uid = port_state_entry.get('last_uid')
expected_uid = port_state_entry.get('expected_uid')
last_polled = port_state_entry.get('last_polled_at')
if not last_polled:
return 0 # UNKNOWN
if last_uid is None:
return 1 # DISCONNECTED
if expected_uid and last_uid != expected_uid:
return 2 # ILLEGAL
return 3 # CONNECTED
def refresh_screen_panel(panel_id):
"""将指定 panel 的数据下发到 Nextion 屏幕。"""
global screen_current_panel_index
panels = get_sorted_panels()
if not panels:
return False, '没有可用的 panel'
panel_ids = [p[0] for p in panels]
if panel_id not in panel_ids:
return False, f'panel 不存在: {panel_id}'
if not screen_display.get_status().get('connected'):
return False, '屏幕串口未连接'
ps = port_state.get(panel_id, {})
now = time.time()
online_count = sum(1 for pid in panel_ids if device_last_seen.get(pid, 0) > now - 60)
idx = panel_ids.index(panel_id)
screen_current_panel_index = idx
# MQTT 状态
mqtt_st = mqtt_client.get_status()
mqtt_connected = mqtt_st.get('connected', False) if isinstance(mqtt_st, dict) else bool(mqtt_st)
delay_ms = config.NEXTION_CMD_DELAY_MS
screen_display.send_cmd(f't2.txt="{config.NEXTION_PANEL_NAME_PREFIX}{idx + 1}"', delay_ms=delay_ms)
screen_display.send_cmd(f't1.txt="{panel_id}"', delay_ms=delay_ms)
screen_display.send_cmd(f't4.txt="{dtu_config.get("firmware_version", "v1.0.0")}"', delay_ms=delay_ms)
mqtt_str = '已连接' if mqtt_connected else '未连接'
screen_display.send_cmd(f'g0.txt="MQTT:{mqtt_str} 当前终端数:{online_count}"', delay_ms=delay_ms)
screen_display.send_cmd(f'g0.pco={0 if mqtt_connected else 63488}', delay_ms=delay_ms)
for port_id in range(1, 25):
pic = _port_status_to_pic(ps.get(port_id, {}))
screen_display.send_cmd(f'p{port_id - 1}.pic={pic}', delay_ms=delay_ms)
logger.info(f"屏幕已刷新: {panel_id} (终端{idx + 1})")
return True, '屏幕已刷新'
def switch_screen_panel(direction):
"""切换当前显示的 panel。"""
panels = get_sorted_panels()
if not panels:
return False, '没有可用的 panel'
global screen_current_panel_index
screen_current_panel_index = (screen_current_panel_index + direction) % len(panels)
panel_id = panels[screen_current_panel_index][0]
logger.info(f"屏幕切换: 终端{screen_current_panel_index + 1} -> {panel_id}")
return refresh_screen_panel(panel_id)
def screen_button_event_handler(page_id, comp_id, event):
"""处理屏幕按钮事件。"""
if event != 0x01:
return
if page_id == 0x00 and comp_id == 0x05:
logger.info('串口屏事件: 上一终端')
switch_screen_panel(-1)
elif page_id == 0x00 and comp_id == 0x06:
logger.info('串口屏事件: 下一终端')
switch_screen_panel(1)
注意:如果 config 在 app.py 中是通过 from config import (...) 导入的,需要把 NEXTION_CMD_DELAY_MS、NEXTION_PANEL_NAME_PREFIX 等加入导入列表。
screen_connected[ ] 找到 dtu_publish_status() 函数中 screen_connected = False 的代码块(约第 641-650 行),替换为:
# 屏幕连接状态:从 Nextion 屏幕实例读取
screen_connected = screen_display.get_status().get('connected', False)
[ ] 在 app.py 的 __main__ 中,找到 auto_connect_serial() 调用之后,添加:
# 连接 Nextion 串口屏
if config.NEXTION_ENABLED:
try:
ok, msg = screen_display.connect(config.NEXTION_SCREEN_PORT, config.NEXTION_SCREEN_BAUD)
if ok:
screen_display.set_button_callback(screen_button_event_handler)
logger.info("Nextion 屏幕连接成功")
# 如果已有 panel,立即显示第一个
panels = get_sorted_panels()
if panels:
refresh_screen_panel(panels[0][0])
else:
logger.warning(f"Nextion 屏幕连接失败: {msg}")
except Exception as e:
logger.error(f"Nextion 屏幕初始化异常: {e}")
[ ] 在 port_poll_loop 中,找到 dtu_publish_panel_status(panel_id, addr, panel_ports) 调用之后,添加:
# 如果该 panel 是当前显示的 panel,刷新屏幕
panels = get_sorted_panels()
if panels and panels[screen_current_panel_index][0] == panel_id:
try:
refresh_screen_panel(panel_id)
except Exception as e:
logger.error(f"刷新屏幕失败: {e}")
[ ] 在 mqtt_status_handler(status) 函数末尾(在 if status and dtu_config.get('enabled'): 分支内,订阅主题之后)添加:
# MQTT 状态变化时刷新当前屏幕显示
panels = get_sorted_panels()
if panels and screen_display.get_status().get('connected'):
try:
refresh_screen_panel(panels[screen_current_panel_index][0])
except Exception as e:
logger.error(f"MQTT 状态变化刷新屏幕失败: {e}")
[ ] 在 app.py 任意 API 路由区域添加:
@app.route('/api/screen/status', methods=['GET'])
def get_screen_status():
"""获取 Nextion 屏幕状态。"""
panels = get_sorted_panels()
current_panel_id = None
if panels and 0 <= screen_current_panel_index < len(panels):
current_panel_id = panels[screen_current_panel_index][0]
status = screen_display.get_status()
return jsonify({
'success': True,
'data': {
'connected': status.get('connected', False),
'port': status.get('port'),
'baudrate': status.get('baudrate'),
'current_panel_index': screen_current_panel_index,
'current_panel_id': current_panel_id,
'panel_count': len(panels)
}
})
@app.route('/api/screen/refresh', methods=['POST'])
def refresh_screen():
"""手动刷新当前 panel 到屏幕。"""
panels = get_sorted_panels()
if not panels:
return jsonify({'success': False, 'message': '没有可用的 panel'}), 400
panel_id = panels[screen_current_panel_index][0]
success, message = refresh_screen_panel(panel_id)
if success:
return jsonify({'success': True, 'message': message})
return jsonify({'success': False, 'message': message}), 400
app.py 语法Run:
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
python -m py_compile app.py
Expected: 无输出(编译成功)
git add backend/app.py
git commit -m "feat(nextion): integrate Nextion display with app, add screen APIs"
Files:
backend/tests/test_nextion_display.pyManual: backend/app.py
[ ] Step 1: 运行单元测试
Run:
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
pytest tests/test_nextion_display.py -v
Expected: 3 个测试全部 PASS
在 149 设备上启动后端(确保 /dev/ttyS9 存在):
cd /Users/wenhongquan/Documents/trae_projects/dzxj_dtu/backend
python app.py
在另一个终端调用 API:
curl http://127.0.0.1:5001/api/screen/status
Expected:
{
"success": true,
"data": {
"connected": true,
"port": "/dev/ttyS9",
"baudrate": 9600,
"current_panel_index": 0,
"current_panel_id": "PANEL_dtu_001_1",
"panel_count": 1
}
}
[ ] Step 3: 手动刷新屏幕
curl -X POST http://127.0.0.1:5001/api/screen/refresh
Expected:
{"success": true, "message": "屏幕已刷新"}
同时观察 149 设备上的屏幕是否显示:终端名、版本、MQTT 状态、24 端口图标。
按屏幕上的“上一终端 / 下一终端”按钮,观察日志是否输出:
串口屏事件: 上一终端
屏幕切换: 终端X -> PANEL_...
插拔跳线或触发 Modbus 轮询,观察当前显示 panel 的端口图标是否实时变化。
如果测试通过,可标记版本:
git tag -a v1.0.1-nextion -m "Nextion display integration verified"
Spec coverage:
app.py 集成(初始化、刷新、按钮事件、状态上报) → Task 3Placeholder scan:
Type consistency:
NextionDisplay.connect 返回 (bool, str),与使用处一致。screen_display.get_status() 返回 {'connected': bool, 'port': str|None, 'baudrate': int|None},与使用处一致。screen_current_panel_index 为全局 int,跨函数一致。已知限制:
/api/screen/refresh 会返回失败。refresh_screen_panel 检测到未连接时触发一次重连。