generated from hulk/gd32f4xx_template_cmake_vscode
383 lines
16 KiB
Python
383 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
FilmForge 热压模具 RS485 温控链路
|
|
================================================================
|
|
协议以 ../protocols.md 为准(固件仓根目录, 帧示例 hex 全部脚本生成):
|
|
物理层 RS485 半双工 115200 8N1 无流控(PC 经 USB-RS485 适配器接板)
|
|
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
|
|
响应帧 B5 TYPE LEN <payload> CRC payload 一律 ASCII(ok / err / 24.5 / ON 500 ...)
|
|
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
|
|
上报帧 M2S<ms> 开启后固件按周期主动发 B5 F0 <温度ASCII> CRC, 格式与 M1 应答相同
|
|
|
|
命令语义速记(全表见 protocols.md):
|
|
M1 查温度(OPEN=NTC 开路/SHRT=短路) M2 查上报状态 M2S<ms> 设间隔并开启(100~60000,
|
|
10ms 步进) M2S0 停止 M3/M3S1/M3S0 加热使能(实装: PB12 慢 PWM + PID 按目标温度
|
|
闭环, 开启后真实加热) M4/M4S<n> 目标温度(0~200 整数) M888 软复位 M999 查版本
|
|
M9999 进 OTA(均会复位, GUI 需二次确认)
|
|
通信要点: 全部命令幂等(ack 丢了直接重发); 命令间隔 >=50ms; 响应超时 300ms 重发 x3。
|
|
|
|
仅标准库 + 可选 pyserial(缺失只影响串口功能, 其余照常)。
|
|
"""
|
|
|
|
import collections
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
|
|
try:
|
|
import serial
|
|
import serial.tools.list_ports
|
|
HAS_SERIAL = True
|
|
except ImportError:
|
|
HAS_SERIAL = False
|
|
|
|
HEADER_TX = 0xD5 # 命令帧头(Host -> Device)
|
|
HEADER_RX = 0xB5 # 响应帧头(Device -> Host)
|
|
BOARD_TYPE = 0x01 # 板类型字节
|
|
FRAME_MIN, FRAME_MAX = 5, 32 # 整帧长度上下限(含帧头/CRC)
|
|
DEFAULT_BAUD = 115200
|
|
|
|
# --- 参数范围(protocols.md 命令表) ---
|
|
TEMP_MIN, TEMP_MAX = 0, 200 # 目标温度整数摄氏度
|
|
REPORT_MIN, REPORT_MAX, REPORT_STEP = 100, 60000, 10 # 上报间隔 ms
|
|
REPLY_TIMEOUT = 0.3 # 响应超时建议值(秒), 供 GUI/CLI 参考
|
|
|
|
# --- 响应 TYPE 码语义(protocols.md "响应 TYPE 码"表) ---
|
|
TYPE_NAMES = {
|
|
0xF0: '成功',
|
|
0xF1: 'CRC 校验错',
|
|
0xF3: '板类型错',
|
|
0xF4: '命令格式错',
|
|
0xFD: '参数越界',
|
|
0xFE: '未知命令',
|
|
}
|
|
|
|
SENSOR_OPEN = 'OPEN' # NTC 开路
|
|
SENSOR_SHORT = 'SHRT' # NTC 短路
|
|
|
|
# GUI 预设命令表: (显示名, 命令, 说明), 顺序即下拉顺序
|
|
PRESETS = (
|
|
('查温度', 'M1', '单次测温; OPEN/SHRT 为 NTC 故障态'),
|
|
('查上报状态', 'M2', 'ON <ms> / OFF'),
|
|
('开上报500ms', 'M2S500', '设上报间隔并开启(100~60000, 10ms 步进)'),
|
|
('停上报', 'M2S0', '停止周期上报'),
|
|
('查加热状态', 'M3', 'ON/OFF(加热使能状态)'),
|
|
('开加热', 'M3S1', '开启 PID 闭环控温, 模具将真实加热(需确认)'),
|
|
('关加热', 'M3S0', '关闭加热输出'),
|
|
('查目标温度', 'M4', 'ASCII 摄氏度(如 120.0)'),
|
|
('设目标120C', 'M4S120', '0~200 整数摄氏度, 越界回 FD'),
|
|
('查固件版本', 'M999', '如 v41.0.0.1'),
|
|
('软件复位', 'M888', '回 ok 后复位(慎发)'),
|
|
('进OTA', 'M9999', '复位进 Bootloader(慎发)'),
|
|
)
|
|
|
|
# 会复位设备的命令: 任何入口发送前都须二次确认(protocols.md 通信注意事项 3)
|
|
RESET_COMMANDS = ('M888', 'M9999')
|
|
|
|
Frame = collections.namedtuple('Frame', 'type_code payload raw')
|
|
|
|
|
|
def sum_crc(data):
|
|
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
|
|
return sum(data) & 0xFF
|
|
|
|
|
|
def build_frame(cmd):
|
|
"""ASCII 命令字符串 -> 命令帧 bytes; 空命令/超长/非 ASCII 抛 ValueError"""
|
|
payload = cmd.strip().encode('ascii')
|
|
if not payload:
|
|
raise ValueError('空命令')
|
|
if len(payload) > FRAME_MAX - 4:
|
|
raise ValueError(f'命令过长(>{FRAME_MAX - 4} 字节, 帧上限 {FRAME_MAX})')
|
|
frame = bytes((HEADER_TX, BOARD_TYPE, len(payload))) + payload
|
|
return frame + bytes((sum_crc(frame[1:]),))
|
|
|
|
|
|
class FrameParser:
|
|
"""增量字节流 -> 完整响应帧: 帧头+LEN 定界, CRC 校验, 失配丢当前帧头重新同步。
|
|
|
|
命令/响应/上报帧共用同一字节流; payload 理论上可能含 0xB5 造成同步偏移
|
|
(伪帧头 CRC 必错会被跳过), 下一帧自动恢复, 对监视工具可接受。
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._buf = bytearray()
|
|
|
|
def feed(self, data):
|
|
"""喂入新收到的字节, 返回本次解析出的完整 Frame 列表"""
|
|
self._buf += data
|
|
frames = []
|
|
while True:
|
|
i = self._buf.find(HEADER_RX)
|
|
if i < 0:
|
|
self._buf.clear()
|
|
return frames
|
|
if i: # 丢弃帧头前的噪声
|
|
del self._buf[:i]
|
|
if len(self._buf) < 3: # 帧头/TYPE/LEN 尚不完整
|
|
return frames
|
|
length = self._buf[2]
|
|
if not 1 <= length <= FRAME_MAX - 4:
|
|
del self._buf[:1] # LEN 非法 -> 伪帧头, 前进 1 字节重同步
|
|
continue
|
|
total = 4 + length
|
|
if len(self._buf) < total: # 整帧未收齐
|
|
return frames
|
|
raw = bytes(self._buf[:total])
|
|
if raw[-1] == sum_crc(raw[1:-1]):
|
|
frames.append(Frame(self._buf[1],
|
|
raw[3:-1].decode('ascii', 'replace').strip(), raw))
|
|
del self._buf[:total]
|
|
else: # CRC 失配 -> 丢帧头重同步
|
|
del self._buf[:1]
|
|
|
|
|
|
def _clean(s):
|
|
"""控制字符 -> '·': 异常载荷按 ASCII 解出的 NUL 等会污染日志行,
|
|
且 Windows 剪贴板遇 NUL 直接截断复制内容"""
|
|
return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s)
|
|
|
|
|
|
def describe(fr):
|
|
"""响应帧 -> 人读文本: TYPE 译码 + payload"""
|
|
pl = _clean(fr.payload)
|
|
if fr.type_code == 0xF0:
|
|
return pl or 'ok'
|
|
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
|
|
if pl:
|
|
text += f' {pl}'
|
|
return text
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# payload 译码助手(全部只认成功帧 F0 的 ASCII 载荷)
|
|
# ------------------------------------------------------------
|
|
_FLOAT_RE = re.compile(r'^[+-]?\d+(?:\.\d+)?$')
|
|
|
|
|
|
def parse_temperature(payload):
|
|
"""M1 应答/上报帧 payload -> (值, 故态)。解析成功返回 (float, None);
|
|
OPEN/SHRT 返回 (None, 'OPEN'|'SHRT'); 其余返回 (None, None) 表示非温度载荷。"""
|
|
p = payload.strip()
|
|
if p in (SENSOR_OPEN, SENSOR_SHORT):
|
|
return None, p
|
|
if _FLOAT_RE.match(p):
|
|
try:
|
|
v = float(p)
|
|
except ValueError: # pragma: no cover - 正则已挡
|
|
return None, None
|
|
if v == v and abs(v) != float('inf'): # 拒绝 nan/inf
|
|
return v, None
|
|
return None, None
|
|
|
|
|
|
def parse_report_state(payload):
|
|
"""M2 应答 'ON <ms>'/'OFF' -> ('ON', ms) / ('OFF', None) / (None, None)"""
|
|
p = payload.strip()
|
|
if p == 'OFF':
|
|
return 'OFF', None
|
|
m = re.match(r'^ON\s+(\d+)$', p)
|
|
if m:
|
|
return 'ON', int(m.group(1))
|
|
return None, None
|
|
|
|
|
|
def parse_onoff(payload):
|
|
"""M3 应答 'ON'/'OFF' -> 'ON'/'OFF'/None"""
|
|
p = payload.strip()
|
|
return p if p in ('ON', 'OFF') else None
|
|
|
|
|
|
def validate_report_interval(text):
|
|
"""上报间隔输入 -> 合法 int(100~60000, 10ms 步进), 否则抛 ValueError"""
|
|
ms = int(str(text).strip())
|
|
if not REPORT_MIN <= ms <= REPORT_MAX:
|
|
raise ValueError(f'上报间隔须在 {REPORT_MIN}~{REPORT_MAX} ms')
|
|
if ms % REPORT_STEP:
|
|
raise ValueError(f'上报间隔须为 {REPORT_STEP}ms 的整数倍')
|
|
return ms
|
|
|
|
|
|
def validate_target_temp(text):
|
|
"""目标温度输入 -> 合法 int(0~200 整数摄氏度), 否则抛 ValueError"""
|
|
t = float(str(text).strip())
|
|
if t != int(t):
|
|
raise ValueError('目标温度只支持整数摄氏度')
|
|
t = int(t)
|
|
if not TEMP_MIN <= t <= TEMP_MAX:
|
|
raise ValueError(f'目标温度须在 {TEMP_MIN}~{TEMP_MAX} ℃')
|
|
return t
|
|
|
|
|
|
class FF485:
|
|
"""串口工作线程: 端口/波特率热切换 + 命令队列 + 回调(收发全在后台线程)。
|
|
|
|
on_event(str) 收发日志行(已译码, 附原始 hex)
|
|
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出)
|
|
on_frame(frame, t_rx) 结构化帧, t_rx 为串口线程收到该帧时刻的
|
|
time.time()(在读取点打戳, 不受 GUI 轮询延迟影响)
|
|
回调在后台线程触发, GUI 侧需自行投递回主线程(queue + Qt 定时器轮询)。
|
|
"""
|
|
|
|
def __init__(self, on_event, on_status, on_frame=None):
|
|
self.on_event, self.on_status, self.on_frame = on_event, on_status, on_frame
|
|
self.q = queue.Queue()
|
|
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
|
|
self._open_key = None # 当前已打开串口的参数元组
|
|
self.ser = None
|
|
self.parser = FrameParser()
|
|
self._lock = threading.Lock()
|
|
threading.Thread(target=self._run, daemon=True).start()
|
|
|
|
@staticmethod
|
|
def list_ports_info():
|
|
"""枚举串口, 返回 [(设备名, 显示标签), ...]。
|
|
|
|
标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉, COM 号已在最前),
|
|
多口描述完全相同(同型号多口)时追加物理位置/序列号消歧。
|
|
"""
|
|
if not HAS_SERIAL:
|
|
return []
|
|
items = []
|
|
for p in serial.tools.list_ports.comports():
|
|
desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(),
|
|
flags=re.I)
|
|
if desc.upper() == p.device.upper():
|
|
desc = ''
|
|
label = f'{p.device} {desc}'.rstrip()
|
|
tail = p.location or p.serial_number or ''
|
|
items.append((p.device, label, tail))
|
|
counts = collections.Counter(label for _d, label, _t in items)
|
|
out = []
|
|
for dev, label, tail in items:
|
|
if counts[label] > 1 and tail: # 同标签多口: 附物理位置消歧
|
|
label = f'{label} [{tail}]'
|
|
out.append((dev, label))
|
|
return out
|
|
|
|
def open(self, port, baud=DEFAULT_BAUD, bytesize=8, parity='N', stopbits=1):
|
|
if not HAS_SERIAL:
|
|
self.on_status('未安装 pyserial: py -m pip install pyserial')
|
|
return
|
|
with self._lock:
|
|
self._target = (port, int(baud), int(bytesize),
|
|
str(parity).upper(), int(stopbits))
|
|
|
|
def close(self):
|
|
with self._lock:
|
|
self._target = None
|
|
|
|
def is_connected(self):
|
|
with self._lock:
|
|
return self.ser is not None
|
|
|
|
def send(self, cmd):
|
|
"""发送一条 ASCII 命令(自动组帧); 未连接/命令非法走 on_status 提示"""
|
|
with self._lock:
|
|
ser = self.ser
|
|
if ser is None:
|
|
self.on_status('未连接串口, 命令被丢弃')
|
|
return
|
|
try:
|
|
frame = build_frame(cmd)
|
|
except ValueError as e:
|
|
self.on_status(f'命令非法: {e}')
|
|
return
|
|
self.q.put((cmd, frame))
|
|
|
|
def _run(self):
|
|
# 串口参数映射(pyserial 常量): 数据位/校验/停止位
|
|
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
|
|
6: serial.SIXBITS, 5: serial.FIVEBITS}
|
|
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
|
|
'O': serial.PARITY_ODD}
|
|
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
|
|
while True:
|
|
with self._lock:
|
|
target = self._target
|
|
cur = self._open_key
|
|
if target != cur:
|
|
if self.ser is not None:
|
|
try:
|
|
self.ser.close()
|
|
except Exception:
|
|
pass
|
|
self.ser = None
|
|
self._open_key = None
|
|
self.on_status('已断开')
|
|
if target:
|
|
port, baud, dbsize, parity, sbits = target
|
|
try:
|
|
self.ser = serial.Serial(
|
|
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
|
|
parity=parities.get(parity, serial.PARITY_NONE),
|
|
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
|
|
timeout=0.15,
|
|
write_timeout=1.0) # 写超时: 转换器停滞时 write 会
|
|
# 永久阻塞并卡死线程, 必须设上限
|
|
self.parser = FrameParser()
|
|
self._open_key = target
|
|
self.on_status(f'已连接 {port} @ {baud} '
|
|
f'{dbsize}{parity}{sbits}')
|
|
except Exception as e:
|
|
self.ser = None
|
|
with self._lock:
|
|
self._target = None
|
|
blob = str(e) + repr(e)
|
|
if 'PermissionError' in blob or '拒绝访问' in blob \
|
|
or 'Access is denied' in blob:
|
|
hint = '串口被占用(串口助手/另一实例?)'
|
|
elif 'FileNotFoundError' in blob or '找不到指定的' in blob \
|
|
or 'No such file' in blob:
|
|
hint = '串口不存在,请刷新后重选'
|
|
else:
|
|
hint = '无法打开,详情见日志'
|
|
# 状态标签只放短提示(长异常串会撑大参数行把窗口拉长),
|
|
# 完整异常走 on_event 进收发日志
|
|
self.on_status(f'连接失败: {hint}')
|
|
self.on_event(f'连接失败: {e!r}')
|
|
if self.ser is not None:
|
|
try:
|
|
item = None
|
|
try:
|
|
item = self.q.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
if item is not None:
|
|
cmd, frame = item
|
|
sent = False
|
|
try:
|
|
self.ser.write(frame)
|
|
sent = True
|
|
except serial.SerialTimeoutException:
|
|
# 写超时(适配器停滞): 只丢这条命令, 链路保留。
|
|
# 勿并入下方 OSError——瞬时停滞曾被当成"设备拔出"
|
|
# 关端口断链, 迫使用户手动重连(适配器换向本就不稳)
|
|
self.on_event(f'✕ {cmd} 写超时未发出, 链路保留(可重发)')
|
|
if sent:
|
|
self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]')
|
|
# 按到达量读取, 不攒大块: 固定 read(256) 在持续帧流下要攒满
|
|
# 256 字节才返回(win32 字节间隔超时 150ms 因帧间隔更短永不
|
|
# 触发, 100ms 上报 ≈ 2.5s 一批), 表现为曲线/命令全按 3s 一卡
|
|
data = self.ser.read(self.ser.in_waiting or 1)
|
|
if data:
|
|
t_rx = time.time() # 在读取点打时间戳(曲线/CSV 用)
|
|
for fr in self.parser.feed(data):
|
|
if self.on_frame is not None:
|
|
self.on_frame(fr, t_rx)
|
|
self.on_event(f'← {describe(fr)} '
|
|
f'[{fr.raw.hex(" ").upper()}]')
|
|
except OSError:
|
|
try:
|
|
self.ser.close()
|
|
except Exception:
|
|
pass
|
|
self.ser = None
|
|
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
|
|
with self._lock:
|
|
self._target = None
|
|
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
|
time.sleep(0.02)
|