anheng_scale.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. # 安衡问答模式指令(秤设置COMMOD=5时可用)
  17. CMD_TARE = "ST\r\n" # 硬件去皮
  18. CMD_ZERO = "SZ\r\n" # 硬件归零
  19. CMD_READ_NET = "RN\r\n" # 读取净重
  20. WEIGHT_RE = re.compile(r"([+-]?\s*\d+(?:\.\d+)?)\s*(kg|g)", re.IGNORECASE)
  21. # 单例:用 __new__ 方式(与 BlueToothMode/Mcu 一致)。
  22. # 不能用 SingletonType 元类:它的 _instance_lock 是全部单例类共享的不可重入锁,
  23. # AnhengScale 在 DeviceControl.__init__ 内创建时(DeviceControl 也是 SingletonType 单例)会死锁
  24. _instance = None
  25. def __new__(cls, *args, **kwargs):
  26. """如果当前没有实例时,调用父类__new__方法,生成实例,有则返回保存的实例。"""
  27. if cls._instance is None:
  28. cls._instance = super().__new__(cls)
  29. return cls._instance
  30. def __init__(self, baudrate: int = 9600, websocket_manager: Optional[ConnectionManager] = None):
  31. # __init__ 每次调用 AnhengScale(...) 都会执行,只允许初始化一次
  32. if getattr(self, "_initialized", False):
  33. return
  34. self._initialized = True
  35. self.baudrate = baudrate
  36. # 用于向推送电子秤数据消息(与 BaseClass.sendSocketMessage 同通道)
  37. self.websocket_manager = websocket_manager
  38. self.ser: Optional[serial.Serial] = None
  39. # 串口缓冲区,处理粘包
  40. self._buf = b""
  41. # 解析得到的实时状态
  42. self.latest_data: Dict[str, Any] = {
  43. "stable": None, # True稳定 / False不稳定 / None无数据
  44. "mode": None, # TR净重 / NT毛重 / None
  45. "raw_grams": 0.0, # 原始重量(克),秤上报的原始值
  46. "net_grams": 0.0, # 软件去皮后净重 = raw_grams - tare_grams
  47. }
  48. # 软件本地皮重(程序层面去皮,不操作秤硬件)
  49. self.tare_grams: float = 0.0
  50. # 后台读取线程标记
  51. self._running = False
  52. self._read_thread: Optional[threading.Thread] = None
  53. # 数据推送循环标记(防止重复创建 run 任务)
  54. self._pushing = False
  55. @staticmethod
  56. def get_all_com_ports() -> list[dict]:
  57. """获取本机全部串口列表,返回 [{"device":"COM3","desc":"xxx"},...]"""
  58. res = []
  59. for p in serial.tools.list_ports.comports():
  60. res.append({
  61. "device": p.device,
  62. "desc": p.description
  63. })
  64. return res
  65. def connect(self, port_name: str) -> bool:
  66. """
  67. 打开串口,启动后台线程持续接收数据
  68. :param port_name: 串口名称,如 "COM3"
  69. :return: True成功 / False失败
  70. """
  71. if self.is_connected():
  72. self.disconnect()
  73. try:
  74. self.ser = serial.Serial(
  75. port=port_name,
  76. baudrate=self.baudrate
  77. )
  78. except Exception as e:
  79. print(f"打开串口失败: {e}")
  80. self.ser = None
  81. return False
  82. self._buf = b""
  83. self._running = True
  84. # 启动后台读取线程
  85. self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
  86. self._read_thread.start()
  87. return True
  88. # 探测窗口:连续输出模式下秤约每200ms上报一行,1.2s足以收到多行
  89. PROBE_TOTAL_WAIT = 1.2
  90. # 被动收集0.5s仍无数据时,发送RN主动询问(兼容COMMOD=5问答模式)
  91. PROBE_CMD_DELAY = 0.5
  92. def probe_connect(self, port_name: str) -> bool:
  93. """
  94. 探测并连接:先以9600打开串口收集数据,确认是安衡电子秤后才真正连接。
  95. 用于自动识别场景,避免把其他设备(如同样使用Prolific适配器的MCU)误认成电子秤。
  96. :param port_name: 串口名称,如 "COM3"
  97. :return: True是电子秤且已连接 / False不是电子秤(串口已关闭,可稍后重试)
  98. """
  99. if self.is_connected():
  100. self.disconnect()
  101. try:
  102. ser = serial.Serial(port=port_name, baudrate=self.baudrate, timeout=0.1)
  103. except Exception as e:
  104. print(f"打开串口失败: {e}")
  105. return False
  106. try:
  107. ser.reset_input_buffer()
  108. except Exception:
  109. pass
  110. collected = b""
  111. start = time.time()
  112. cmd_sent = False
  113. while time.time() - start < self.PROBE_TOTAL_WAIT:
  114. if ser.in_waiting:
  115. collected += ser.read(ser.in_waiting)
  116. # 连续模式下很快就有数据;问答模式(COMMOD=5)无主动上报,发RN询问
  117. if not cmd_sent and time.time() - start >= self.PROBE_CMD_DELAY:
  118. try:
  119. ser.write(b"RN\r\n")
  120. cmd_sent = True
  121. except Exception:
  122. pass
  123. if self._is_anheng_data(collected):
  124. break
  125. time.sleep(0.05)
  126. # if not self._is_anheng_data(collected):
  127. # print(f"探测{port_name}:未收到安衡电子秤特征数据,判定非电子秤")
  128. # ser.close()
  129. # return False
  130. # 确认是电子秤,接管串口并启动后台读取线程
  131. self.ser = ser
  132. self._buf = collected
  133. self._running = True
  134. self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
  135. self._read_thread.start()
  136. return True
  137. @staticmethod
  138. def _is_anheng_data(data: bytes) -> bool:
  139. """判断数据是否符合安衡协议特征:状态位(ST/US) + 模式(TR/NT) + 重量值"""
  140. if not data:
  141. return False
  142. for line in data.split(b"\n"):
  143. text = line.decode("ascii", errors="ignore").strip()
  144. if not text:
  145. continue
  146. parts = [p.strip() for p in text.split(",")]
  147. has_status = any(p in ("ST", "US") for p in parts)
  148. has_mode = any(p in ("TR", "NT") for p in parts)
  149. has_weight = any(AnhengScale.WEIGHT_RE.fullmatch(p) for p in parts)
  150. if has_status and has_mode and has_weight:
  151. return True
  152. return False
  153. def disconnect(self):
  154. """关闭串口,停止后台读取线程"""
  155. self._running = False
  156. if self._read_thread is not None:
  157. self._read_thread.join(timeout=1.0)
  158. if self.ser is not None:
  159. try:
  160. self.ser.close()
  161. except Exception:
  162. pass
  163. self.ser = None
  164. def is_connected(self) -> bool:
  165. return self.ser is not None and self.ser.is_open
  166. def _background_read_loop(self):
  167. """后台循环读取串口数据"""
  168. while self._running and self.ser and self.ser.is_open:
  169. try:
  170. wait_cnt = self.ser.in_waiting
  171. if wait_cnt > 0:
  172. chunk = self.ser.read(wait_cnt)
  173. self._buf += chunk
  174. self._parse_buffer()
  175. except Exception as e:
  176. print(f"读取异常: {e}")
  177. time.sleep(0.01) # 10ms轮询,和原GUI逻辑对齐
  178. def _parse_buffer(self):
  179. """解析缓冲区,按\n分割每行,解析协议"""
  180. while b"\n" in self._buf:
  181. line_bytes, self._buf = self._buf.split(b"\n", 1)
  182. self._parse_one_line(line_bytes)
  183. def _parse_one_line(self, line: bytes):
  184. try:
  185. text = line.decode("ascii", errors="ignore").strip()
  186. except Exception:
  187. return
  188. if not text:
  189. return
  190. parts = [p.strip() for p in text.split(",")]
  191. stable = None
  192. mode = None
  193. if "ST" in parts:
  194. stable = True
  195. elif "US" in parts:
  196. stable = False
  197. if "TR" in parts:
  198. mode = "TR"
  199. elif "NT" in parts:
  200. mode = "NT"
  201. if stable is not None:
  202. self.latest_data["stable"] = stable
  203. if mode is not None:
  204. self.latest_data["mode"] = mode
  205. # 提取重量
  206. for seg in parts:
  207. match = self.WEIGHT_RE.fullmatch(seg)
  208. if not match:
  209. continue
  210. val_str = match.group(1).replace(" ", "")
  211. val = float(val_str)
  212. unit = match.group(2).lower()
  213. raw_g = val * 1000.0 if unit == "kg" else val
  214. self.latest_data["raw_grams"] = raw_g
  215. # 软件去皮计算
  216. self.latest_data["net_grams"] = raw_g - self.tare_grams
  217. return
  218. def get_latest(self) -> Dict[str, Any]:
  219. """
  220. 获取最新读数
  221. 返回:
  222. {
  223. "stable": True/False/None,
  224. "mode": "TR"/"NT"/None,
  225. "raw_grams": float, # 秤原始重量g
  226. "net_grams": float # 软件去皮之后净重g
  227. }
  228. """
  229. return self.latest_data.copy()
  230. async def send_data_message(self, data: Dict[str, Any]):
  231. """
  232. 推送电子秤数据消息到前端(与 BaseClass.sendSocketMessage 一致,走全局消息队列)
  233. :param data: get_latest() 返回的数据
  234. """
  235. if self.websocket_manager is None:
  236. return
  237. payload = {
  238. "code": 0,
  239. "msg": "安衡电子秤数据",
  240. "status": 2,
  241. "data": data,
  242. "msg_type": "anheng_scale",
  243. }
  244. await message_queue.put(payload)
  245. def start_run(self):
  246. """启动数据推送循环(参考 LineControl 模式:create_task(run())),防止重复创建"""
  247. if self._pushing:
  248. return
  249. self._pushing = True
  250. loop = asyncio.get_event_loop()
  251. loop.create_task(self.run())
  252. async def run(self):
  253. """电子秤数据推送循环(参考 LineControl.run 模式)"""
  254. while True:
  255. await asyncio.sleep(0.06)
  256. if not self.is_connected():
  257. break
  258. data = self.get_latest()
  259. await self.send_data_message(data)
  260. self._pushing = False
  261. def software_tare(self) -> bool:
  262. """
  263. 【软件本地去皮,不发送指令给电子秤硬件】
  264. 只有秤稳定时才能执行,记录当前原始重量作为皮重
  265. :return: True成功;False失败(未稳定)
  266. """
  267. if self.latest_data.get("stable") is not True:
  268. return False
  269. self.tare_grams = self.latest_data["raw_grams"]
  270. self.latest_data["net_grams"] = self.latest_data["raw_grams"] - self.tare_grams
  271. return True
  272. def clear_software_tare(self):
  273. """清除软件去皮,皮重置0"""
  274. self.tare_grams = 0.0
  275. self.latest_data["net_grams"] = self.latest_data["raw_grams"]
  276. def send_hardware_cmd(self, cmd: str):
  277. """底层:向串口发送原始指令,需要秤设置COMMOD=5问答模式才生效"""
  278. if not self.is_connected():
  279. return False
  280. try:
  281. self.ser.write(cmd.encode("ascii"))
  282. return True
  283. except Exception as e:
  284. print(f"发送指令失败 {e}")
  285. return False
  286. def hardware_tare(self):
  287. """【硬件去皮】发送指令让电子秤本体执行去皮,秤必须开启问答模式COMMOD=5"""
  288. return self.send_hardware_cmd(self.CMD_TARE)
  289. def hardware_zero(self):
  290. """【硬件归零】发送指令让电子秤本体归零,秤必须开启问答模式COMMOD=5"""
  291. return self.send_hardware_cmd(self.CMD_ZERO)