anheng_scale.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import asyncio
  2. import re
  3. import serial
  4. import serial.tools.list_ports
  5. import threading
  6. import time
  7. from typing import Optional, Dict, Any
  8. from utils.common import message_queue
  9. from sockets import ConnectionManager
  10. class AnhengScale:
  11. """
  12. 安衡电子秤 纯后台串口类,无GUI(单例)
  13. 协议格式示例: ST,TR,+ 0.0666kg
  14. 波特率默认9600,8N1
  15. 实时连接检测 + 自动重连:
  16. - 读取线程通过两种方式判定设备断开:
  17. (1) 读取异常/连续多次读失败 —— 覆盖"拔出USB"(COM句柄失效,读取会抛异常);
  18. (2) 数据看门狗超时(默认开启 data_timeout=3.0s)—— 覆盖"设备断电但USB仍连着",
  19. 此时COM仍能正常打开但秤不再发数据,靠"一段时间收不到任何数据"来判定断电;
  20. - 断开后由后台重连线程重新扫描串口(兼容重插后COM号变化),
  21. 按设备特征识别安衡电子秤并严格探测确认(需收到数据)后重连,指数退避重试直至成功或被显式关闭;
  22. 设备重新上电 / 重新插拔USB后会自动恢复连接。
  23. - 连接状态(connected/disconnected/reconnecting/reconnected/closed)
  24. 通过全局消息队列推送(msg_type=anheng_scale_status),前端可选消费。
  25. """
  26. # 安衡问答模式指令(秤设置COMMOD=5时可用)
  27. CMD_TARE = "ST\r\n" # 硬件去皮
  28. CMD_ZERO = "SZ\r\n" # 硬件归零
  29. CMD_READ_NET = "RN\r\n" # 读取净重
  30. WEIGHT_RE = re.compile(r"([+-]?\s*\d+(?:\.\d+)?)\s*(kg|g)", re.IGNORECASE)
  31. # ===== 实时连接检测 + 自动重连 参数 =====
  32. # 安衡电子秤串口设备特征(用于重插后重新扫描识别)
  33. SCALE_DESC_KEYWORD = "Prolific USB-to-Serial Comm Port"
  34. RECONNECT_INITIAL_DELAY = 1.0 # 首次重连等待(秒)
  35. RECONNECT_MAX_DELAY = 5.0 # 重连退避上限(秒)
  36. RECONNECT_STABLE_WAIT = 1.0 # 重连成功后稳定观察(秒)
  37. READ_ERROR_THRESHOLD = 3 # 连续读取异常达到该次数即判定设备断开
  38. # 单例:用 __new__ 方式(与 BlueToothMode/Mcu 一致)。
  39. # 不能用 SingletonType 元类:它的 _instance_lock 是全部单例类共享的不可重入锁,
  40. # AnhengScale 在 DeviceControl.__init__ 内创建时(DeviceControl 也是 SingletonType 单例)会死锁
  41. _instance = None
  42. def __new__(cls, *args, **kwargs):
  43. """如果当前没有实例时,调用父类__new__方法,生成实例,有则返回保存的实例。"""
  44. if cls._instance is None:
  45. cls._instance = super().__new__(cls)
  46. return cls._instance
  47. def __init__(
  48. self,
  49. baudrate: int = 9600,
  50. websocket_manager: Optional[ConnectionManager] = None,
  51. auto_reconnect: bool = True,
  52. data_timeout: Optional[float] = 3.0,
  53. ):
  54. # __init__ 每次调用 AnhengScale(...) 都会执行,只允许初始化一次
  55. if getattr(self, "_initialized", False):
  56. return
  57. self._initialized = True
  58. self.baudrate = baudrate
  59. self.is_send_message = False
  60. # 用于向推送电子秤数据消息(与 BaseClass.sendSocketMessage 同通道)
  61. self.websocket_manager = websocket_manager
  62. self.ser: Optional[serial.Serial] = None
  63. # 串口缓冲区,处理粘包
  64. self._buf = b""
  65. # 解析得到的实时状态
  66. self.latest_data: Dict[str, Any] = {
  67. "stable": None, # True稳定 / False不稳定 / None无数据
  68. "mode": None, # TR净重 / NT毛重 / None
  69. "raw_grams": 0.0, # 原始重量(克),秤上报的原始值
  70. "net_grams": 0.0, # 软件去皮后净重 = raw_grams - tare_grams
  71. }
  72. # 软件本地皮重(程序层面去皮,不操作秤硬件)
  73. self.tare_grams: float = 0.0
  74. # 后台读取线程标记
  75. self._running = False
  76. self._read_thread: Optional[threading.Thread] = None
  77. # 数据推送循环标记(防止重复创建 run 任务)
  78. self._pushing = False
  79. # ===== 实时连接检测 + 自动重连 状态 =====
  80. self._loop = None # 主事件循环(异步上下文进入时捕获,供后台线程安全推送状态)
  81. self._port_name: str = "" # 当前/上次串口号(重插后可能变化,仅作优先提示)
  82. self._should_run: bool = False # 电子秤处于"应连接"状态(start_run 开启,disconnect 关闭)
  83. self._manually_closed: bool = False # 是否被显式 disconnect() 关闭(关闭后不再自动重连)
  84. self._device_lost: bool = False # 是否已检测到设备断开
  85. self._auto_reconnect: bool = auto_reconnect # 是否启用自动重连
  86. # 数据看门狗超时(秒):连续输出模式下,超过该时长未收到任何数据即判定"设备断电/失联"并触发断开重连。
  87. # 连续模式约200ms上报一行,默认 3.0s(≈15 行未上报)足够判定断电且不易误判;
  88. # 调小=断电检测更快但更敏感,调大=更不易误判。问答模式(COMMOD=5)无主动上报,请传 None 关闭以免误判。
  89. self.data_timeout: Optional[float] = data_timeout
  90. self._last_data_time: float = 0.0 # 最近一次收到有效数据的时间
  91. self._reconnecting: bool = False # 重连线程是否存活
  92. self._reconnect_thread: Optional[threading.Thread] = None
  93. @staticmethod
  94. def get_all_com_ports() -> list[dict]:
  95. """获取本机全部串口列表,返回 [{"device":"COM3","desc":"xxx"},...]"""
  96. res = []
  97. for p in serial.tools.list_ports.comports():
  98. res.append({
  99. "device": p.device,
  100. "desc": p.description
  101. })
  102. return res
  103. def _capture_loop(self):
  104. """在异步上下文中捕获主事件循环;非异步上下文(如重连线程)则保留已有值,避免误建循环。"""
  105. try:
  106. self._loop = asyncio.get_running_loop()
  107. except RuntimeError:
  108. pass
  109. def connect(self, port_name: str) -> bool:
  110. """
  111. 打开串口,启动后台线程持续接收数据
  112. :param port_name: 串口名称,如 "COM3"
  113. :return: True成功 / False失败
  114. """
  115. self._capture_loop()
  116. self._port_name = port_name
  117. self._manually_closed = False
  118. if self.is_connected():
  119. self._close_serial_link()
  120. try:
  121. self.ser = serial.Serial(
  122. port=port_name,
  123. baudrate=self.baudrate
  124. )
  125. except Exception as e:
  126. print(f"打开串口失败: {e}")
  127. self.ser = None
  128. return False
  129. self._buf = b""
  130. self._last_data_time = time.time()
  131. self._running = True
  132. self._should_run = True # 连接即视为"应连接",断开后可自动重连
  133. self._device_lost = False # 连接成功,清除丢失标记
  134. # 启动后台读取线程
  135. self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
  136. self._read_thread.start()
  137. return True
  138. # 探测窗口:连续输出模式下秤约每200ms上报一行,1.2s足以收到多行
  139. PROBE_TOTAL_WAIT = 1.2
  140. # 被动收集0.5s仍无数据时,发送RN主动询问(兼容COMMOD=5问答模式)
  141. PROBE_CMD_DELAY = 0.5
  142. def probe_connect(self, port_name: str, verify: bool = False) -> bool:
  143. """
  144. 探测并连接:先以9600打开串口收集数据,确认是安衡电子秤后才真正连接。
  145. 用于自动识别场景,避免把其他设备(如同样使用Prolific适配器的MCU)误认成电子秤。
  146. :param port_name: 串口名称,如 "COM3"
  147. :param verify: True=严格模式,未确认安衡协议特征则返回False(自动重连时使用,避免误连其他Prolific设备);
  148. False=沿用原有行为(打开成功即视为连接),用于首次识别
  149. :return: True是电子秤且已连接 / False不是电子秤(串口已关闭,可稍后重试)
  150. """
  151. self._capture_loop()
  152. self._port_name = port_name
  153. self._manually_closed = False
  154. if self.is_connected():
  155. self._close_serial_link()
  156. try:
  157. ser = serial.Serial(port=port_name, baudrate=self.baudrate, timeout=0.1)
  158. except Exception as e:
  159. print(f"打开串口失败: {e}")
  160. return False
  161. try:
  162. ser.reset_input_buffer()
  163. except Exception:
  164. pass
  165. collected = b""
  166. start = time.time()
  167. cmd_sent = False
  168. while time.time() - start < self.PROBE_TOTAL_WAIT:
  169. try:
  170. if ser.in_waiting:
  171. collected += ser.read(ser.in_waiting)
  172. except Exception:
  173. pass
  174. # 连续模式下很快就有数据;问答模式(COMMOD=5)无主动上报,发RN询问
  175. if not cmd_sent and time.time() - start >= self.PROBE_CMD_DELAY:
  176. try:
  177. ser.write(b"RN\r\n")
  178. cmd_sent = True
  179. except Exception as e:
  180. print("电子秤连接异常", e)
  181. pass
  182. if self._is_anheng_data(collected):
  183. break
  184. time.sleep(0.05)
  185. # 严格模式:未确认安衡协议特征则判定非电子秤(用于自动重连)
  186. if verify and not self._is_anheng_data(collected):
  187. print(f"探测{port_name}:未收到安衡电子秤特征数据,判定非电子秤")
  188. try:
  189. ser.close()
  190. except Exception:
  191. pass
  192. return False
  193. # 确认是电子秤,接管串口并启动后台读取线程
  194. self.ser = ser
  195. self._buf = collected
  196. self._last_data_time = time.time()
  197. self._running = True
  198. self._should_run = True # 连接即视为"应连接",断开后可自动重连
  199. self._device_lost = False # 连接成功,清除丢失标记
  200. self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
  201. self._read_thread.start()
  202. return True
  203. @staticmethod
  204. def _is_anheng_data(data: bytes) -> bool:
  205. """判断数据是否符合安衡协议特征:状态位(ST/US) + 模式(TR/NT) + 重量值"""
  206. if not data:
  207. return False
  208. for line in data.split(b"\n"):
  209. text = line.decode("ascii", errors="ignore").strip()
  210. if not text:
  211. continue
  212. parts = [p.strip() for p in text.split(",")]
  213. has_status = any(p in ("ST", "US") for p in parts)
  214. has_mode = any(p in ("TR", "NT") for p in parts)
  215. has_weight = any(AnhengScale.WEIGHT_RE.fullmatch(p) for p in parts)
  216. if has_status and has_mode and has_weight:
  217. return True
  218. return False
  219. def _close_serial_link(self):
  220. """仅关闭串口链路(停止读取线程+关闭串口),不改变 _should_run/_manually_closed。
  221. 供内部在"断开/重连前"复用,避免误触发整体停止。"""
  222. self._running = False
  223. rt = self._read_thread
  224. # 读取线程自己触发时不能 join 自身,否则死锁
  225. if rt is not None and rt is not threading.current_thread() and rt.is_alive():
  226. rt.join(timeout=1.0)
  227. self._read_thread = None
  228. if self.ser is not None:
  229. try:
  230. self.ser.close()
  231. except Exception:
  232. pass
  233. self.ser = None
  234. def disconnect(self):
  235. """显式关闭:停止后台读取与自动重连(不再自动重连,除非重新 connect/probe_connect)"""
  236. self._manually_closed = True
  237. self._should_run = False
  238. self._device_lost = False
  239. self._push_status("closed")
  240. self._close_serial_link()
  241. def is_connected(self) -> bool:
  242. return self.ser is not None and self.ser.is_open
  243. def _background_read_loop(self):
  244. """后台循环读取串口数据;检测读取异常/数据超时 => 判定设备断开并触发重连"""
  245. consecutive_errors = 0
  246. while self._running:
  247. ser = self.ser
  248. if ser is None or not ser.is_open:
  249. break
  250. try:
  251. wait_cnt = ser.in_waiting
  252. if wait_cnt > 0:
  253. chunk = ser.read(wait_cnt)
  254. if chunk:
  255. self._buf += chunk
  256. self._parse_buffer()
  257. self._last_data_time = time.time()
  258. consecutive_errors = 0
  259. elif self.data_timeout and (time.time() - self._last_data_time) > self.data_timeout:
  260. # 看门狗:连续输出模式下一段时间无任何数据,判定设备失联
  261. self._mark_device_lost("数据超时,判定设备断开")
  262. break
  263. except Exception as e:
  264. consecutive_errors += 1
  265. print(f"电子秤读取异常({consecutive_errors}): {e}")
  266. # 设备拔出后串口读取会抛异常;串口已关闭或连续多次异常 => 判定断开
  267. if not ser.is_open or consecutive_errors >= self.READ_ERROR_THRESHOLD:
  268. self._mark_device_lost(f"读取异常: {e}")
  269. break
  270. time.sleep(0.05)
  271. time.sleep(0.01) # 10ms轮询,和原GUI逻辑对齐
  272. def _parse_buffer(self):
  273. """解析缓冲区,按\n分割每行,解析协议"""
  274. while b"\n" in self._buf:
  275. line_bytes, self._buf = self._buf.split(b"\n", 1)
  276. self._parse_one_line(line_bytes)
  277. def _parse_one_line(self, line: bytes):
  278. try:
  279. text = line.decode("ascii", errors="ignore").strip()
  280. except Exception:
  281. return
  282. if not text:
  283. return
  284. parts = [p.strip() for p in text.split(",")]
  285. stable = None
  286. mode = None
  287. if "ST" in parts:
  288. stable = True
  289. elif "US" in parts:
  290. stable = False
  291. if "TR" in parts:
  292. mode = "TR"
  293. elif "NT" in parts:
  294. mode = "NT"
  295. if stable is not None:
  296. self.latest_data["stable"] = stable
  297. if mode is not None:
  298. self.latest_data["mode"] = mode
  299. # 提取重量
  300. for seg in parts:
  301. match = self.WEIGHT_RE.fullmatch(seg)
  302. if not match:
  303. continue
  304. val_str = match.group(1).replace(" ", "")
  305. val = float(val_str)
  306. unit = match.group(2).lower()
  307. raw_g = val * 1000.0 if unit == "kg" else val
  308. self.latest_data["raw_grams"] = raw_g
  309. # 软件去皮计算
  310. self.latest_data["net_grams"] = raw_g - self.tare_grams
  311. return
  312. def get_latest(self) -> Dict[str, Any]:
  313. """
  314. 获取最新读数
  315. 返回:
  316. {
  317. "stable": True/False/None,
  318. "mode": "TR"/"NT"/None,
  319. "raw_grams": float, # 秤原始重量g
  320. "net_grams": float # 软件去皮之后净重g
  321. }
  322. """
  323. return self.latest_data.copy()
  324. async def send_data_message(self, data: Dict[str, Any]):
  325. """
  326. 推送电子秤数据消息到前端(与 BaseClass.sendSocketMessage 一致,走全局消息队列)
  327. :param data: get_latest() 返回的数据
  328. """
  329. if self.websocket_manager is None:
  330. return
  331. payload = {
  332. "code": 0,
  333. "msg": "安衡电子秤数据",
  334. "status": 2,
  335. "data": data,
  336. "msg_type": "anheng_scale",
  337. }
  338. await message_queue.put(payload)
  339. def _push_status(self, status: str, reason: str = ""):
  340. """
  341. 通过全局消息队列向前端推送连接状态(前端可选消费,msg_type=anheng_scale_status)。
  342. 可从后台线程安全调用(把 put 协程调度到主事件循环执行)。
  343. status: connected / disconnected / reconnecting / reconnected / closed
  344. """
  345. if self.websocket_manager is None:
  346. return
  347. status_text = {
  348. "connected": "电子秤已连接",
  349. "disconnected": "电子秤已断开",
  350. "reconnecting": "电子秤重连中",
  351. "reconnected": "电子秤重连成功",
  352. "closed": "电子秤已关闭",
  353. }.get(status, "电子秤状态")
  354. payload = {
  355. "code": 1 if status in ["disconnected", "reconnecting", 'closed'] else 0,
  356. "msg": status_text if not reason else f"{status_text}:{reason}",
  357. "status": -1 if status in ["disconnected", "reconnecting", 'closed'] else 2,
  358. "data": {"status": status, "port": self._port_name, "reason": reason},
  359. "msg_type": "anheng_scale_status",
  360. }
  361. try:
  362. if self._loop is not None:
  363. asyncio.run_coroutine_threadsafe(message_queue.put(payload), self._loop)
  364. except Exception as e:
  365. print(f"推送电子秤状态失败: {e}")
  366. def start_run(self):
  367. """启动数据推送循环(参考 LineControl 模式:create_task(run())),防止重复创建"""
  368. if self._pushing:
  369. return
  370. self._pushing = True
  371. self._should_run = True
  372. self._manually_closed = False
  373. self._capture_loop()
  374. try:
  375. if self._loop is None:
  376. self._loop = asyncio.get_event_loop()
  377. self._loop.create_task(self.run())
  378. except Exception as e:
  379. print(f"启动电子秤推送循环失败: {e}")
  380. self._pushing = False
  381. return
  382. self._push_status("connected")
  383. async def run(self):
  384. """电子秤数据推送循环(参考 LineControl.run 模式)。
  385. 断开期间不推送读数但不退出;自动重连成功后自动恢复,无需重启本循环。"""
  386. while self._should_run:
  387. await asyncio.sleep(0.06)
  388. if not self.is_connected():
  389. continue
  390. data = self.get_latest()
  391. if self.is_send_message:
  392. await self.send_data_message(data)
  393. self.is_send_message = False
  394. self._pushing = False
  395. # ===== 实时连接检测 + 自动重连 核心 =====
  396. def _mark_device_lost(self, reason: str):
  397. """检测到设备断开(幂等)。关闭串口链路,推送状态并拉起自动重连。"""
  398. if self._device_lost:
  399. return
  400. self._device_lost = True
  401. print(f"安衡电子秤断开:{reason}")
  402. self._push_status("disconnected", reason)
  403. self._close_serial_link()
  404. if self._auto_reconnect and not self._manually_closed and self._should_run:
  405. self._ensure_reconnect_thread()
  406. def _ensure_reconnect_thread(self):
  407. """确保自动重连线程存活(已存活则不重复创建)"""
  408. if self._reconnecting:
  409. return
  410. self._reconnecting = True
  411. self._reconnect_thread = threading.Thread(target=self._reconnect_loop, daemon=True)
  412. self._reconnect_thread.start()
  413. def _interruptible_sleep(self, seconds: float):
  414. """可被显式关闭打断的休眠,避免手动 disconnect 后线程长时间卡住"""
  415. end = time.time() + seconds
  416. while time.time() < end and self._should_run and not self._manually_closed:
  417. time.sleep(0.2)
  418. def _find_scale_port(self):
  419. """重新扫描串口,返回安衡电子秤候选串口号(兼容重插后COM号变化)。
  420. 优先上次记录的COM口,否则取第一个 Prolific 串口;找不到返回 None。"""
  421. try:
  422. ports = list(serial.tools.list_ports.comports())
  423. except Exception as e:
  424. print(f"扫描串口失败: {e}")
  425. return None
  426. if self._port_name:
  427. for p in ports:
  428. if p.device == self._port_name and self.SCALE_DESC_KEYWORD in (p.description or ""):
  429. return p.device
  430. for p in ports:
  431. if self.SCALE_DESC_KEYWORD in (p.description or ""):
  432. return p.device
  433. return None
  434. def _try_reconnect(self) -> bool:
  435. """尝试重连一次:重新扫描定位电子秤 => 严格探测确认 => 打开并启动读取线程"""
  436. port = self._find_scale_port()
  437. if not port:
  438. print("安衡电子秤重连:未发现候选串口")
  439. return False
  440. print(f"安衡电子秤重连:尝试连接 {port}")
  441. return self.probe_connect(port, verify=True)
  442. def _reconnect_loop(self):
  443. """自动重连主循环:设备丢失 => 指数退避重试(重新扫描+探测) => 成功后进入稳定观察,
  444. 直到被显式 disconnect() 停止。"""
  445. backoff = self.RECONNECT_INITIAL_DELAY
  446. announced = False
  447. while self._should_run and not self._manually_closed:
  448. if not self._device_lost and self.is_connected():
  449. # 健康状态:复位退避与提示标记,等待下一次丢失
  450. backoff = self.RECONNECT_INITIAL_DELAY
  451. announced = False
  452. self._interruptible_sleep(0.3)
  453. continue
  454. if not announced:
  455. self._push_status("reconnecting")
  456. announced = True
  457. if self._try_reconnect():
  458. self._last_data_time = time.time()
  459. self._push_status("reconnected")
  460. backoff = self.RECONNECT_INITIAL_DELAY
  461. self._interruptible_sleep(self.RECONNECT_STABLE_WAIT)
  462. continue
  463. self._interruptible_sleep(backoff)
  464. backoff = min(backoff * 2, self.RECONNECT_MAX_DELAY)
  465. self._reconnecting = False
  466. self._reconnect_thread = None
  467. # 退出时兜底关闭串口链路,处理"重连探测与手动关闭竞态"可能残留的串口/读取线程
  468. self._close_serial_link()
  469. def software_tare(self) -> bool:
  470. """
  471. 【软件本地去皮,不发送指令给电子秤硬件】
  472. 只有秤稳定时才能执行,记录当前原始重量作为皮重
  473. :return: True成功;False失败(未稳定)
  474. """
  475. if self.latest_data.get("stable") is not True:
  476. return False
  477. self.tare_grams = self.latest_data["raw_grams"]
  478. self.latest_data["net_grams"] = self.latest_data["raw_grams"] - self.tare_grams
  479. return True
  480. def clear_software_tare(self):
  481. """清除软件去皮,皮重置0"""
  482. self.tare_grams = 0.0
  483. self.latest_data["net_grams"] = self.latest_data["raw_grams"]
  484. def send_hardware_cmd(self, cmd: str):
  485. """底层:向串口发送原始指令,需要秤设置COMMOD=5问答模式才生效"""
  486. if not self.is_connected():
  487. return False
  488. try:
  489. self.ser.write(cmd.encode("ascii"))
  490. return True
  491. except Exception as e:
  492. print(f"发送指令失败 {e}")
  493. return False
  494. def hardware_tare(self):
  495. """【硬件去皮】发送指令让电子秤本体执行去皮,秤必须开启问答模式COMMOD=5"""
  496. return self.send_hardware_cmd(self.CMD_TARE)
  497. def hardware_zero(self):
  498. """【硬件归零】发送指令让电子秤本体归零,秤必须开启问答模式COMMOD=5"""
  499. return self.send_hardware_cmd(self.CMD_ZERO)