SerialMotor.cs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. using System;
  2. using System.Diagnostics;
  3. using System.IO.Ports;
  4. using System.Threading;
  5. namespace AutoFocusTool.Serial
  6. {
  7. /// <summary>
  8. /// 单舱室串口控制器(马达 + LED)。自包含,无外部实体依赖。
  9. /// 采用同步请求-响应:写命令帧 → 按该命令固定回复长度阻塞读 → 校验。
  10. /// 比原工程的事件+环形缓冲简单,适合调试台单线程操作。
  11. /// 串口参数固定:9600 8N1,读写超时 3000ms。
  12. /// </summary>
  13. public class SerialMotor : IDisposable
  14. {
  15. private readonly SerialPort _port;
  16. private readonly object _ioLock = new object();
  17. /// <summary>日志回调(发送/接收/错误)</summary>
  18. public Action<string> Log;
  19. public string PortName { get; }
  20. public bool IsOpen => _port != null && _port.IsOpen;
  21. // ───────────────────────── 读超时策略(方案甲:按命令类型动态超时)─────────────────────────
  22. // 背景/根因:下位机的“移动命令”是开环阻塞式——它在机械真正转到位之后才回复
  23. // (回复即“到位确认”)。因此回复耗时 ∝ 移动距离。大行程移动(如水平从 206150
  24. // 转到 71500,跨 ~13.5 万脉冲)实测需 6 秒以上。
  25. // 旧实现把读超时写死 3000ms:大行程移动还没等到回复就被判“接收超时”→ 返回 null →
  26. // 上层误判“移动失败”并重试,且后续步骤在电机尚未稳定到位时就抓图,导致粗对焦
  27. // 整段检不出 well 圆、对焦退回边界。(详见 well1 标定日志:仅 2 次超时,均为本 well
  28. // 首次大行程移动,后续 166 次小步移动 0 超时。)
  29. // 方案甲:把“移动类命令”与“查询类命令”分开给超时——
  30. // · 移动类(电机绝对/相对/复位,命令码 CMD_MOTOR=0x05):长超时,覆盖最大行程移动耗时。
  31. // · 查询类(握手/读EEPROM/读电机位置/设IO等):下位机立即回复,保持短超时即可。
  32. /// <summary>移动类命令(电机运动,命令码0x05)的读超时(ms)。需覆盖最大行程移动耗时,默认12000。</summary>
  33. public int MoveReadTimeoutMs { get; set; } = 12000;
  34. /// <summary>查询类命令(握手/读EEPROM/读位置/设IO)的读超时(ms)。下位机立即回复,默认3000。</summary>
  35. public int QueryReadTimeoutMs { get; set; } = 3000;
  36. public SerialMotor(string portName)
  37. {
  38. PortName = portName;
  39. _port = new SerialPort
  40. {
  41. PortName = portName,
  42. BaudRate = 9600,
  43. DataBits = 8,
  44. StopBits = StopBits.One,
  45. Parity = Parity.None,
  46. // SerialPort.ReadTimeout 取两者较大值:实际每次读用的超时由 Send() 按命令类型
  47. // 再细分(见 ReadTimeoutForCmd)。这里设为大值,避免底层 Read 提前抛 TimeoutException。
  48. ReadTimeout = 12000,
  49. WriteTimeout = 3000,
  50. };
  51. }
  52. public bool Open()
  53. {
  54. try
  55. {
  56. if (!_port.IsOpen) _port.Open();
  57. _port.DiscardInBuffer();
  58. _port.DiscardOutBuffer();
  59. return true;
  60. }
  61. catch (Exception ex)
  62. {
  63. Log?.Invoke($"[{PortName}] 打开串口失败: {ex.Message}");
  64. return false;
  65. }
  66. }
  67. public void Close()
  68. {
  69. try { if (_port.IsOpen) _port.Close(); } catch { }
  70. }
  71. /// <summary>
  72. /// 发送命令帧并读取固定长度回复。返回回复字节(失败返回 null)。
  73. /// </summary>
  74. /// <param name="frame">完整命令帧(含校验)</param>
  75. /// <param name="extraWaitMs">读到回复后额外等待(电机到位延时)</param>
  76. public byte[] Send(byte[] frame, int extraWaitMs = 0)
  77. {
  78. if (!IsOpen && !Open()) return null;
  79. int replyLen = Protocol.ReplyLength(frame[1]);
  80. lock (_ioLock)
  81. {
  82. try
  83. {
  84. _port.DiscardInBuffer();
  85. _port.Write(frame, 0, frame.Length);
  86. Log?.Invoke($"[{PortName}] 发送: {ToHex(frame)}");
  87. // 方案甲:按命令类型选读超时。移动类命令要等机械到位才回复,耗时随行程增长,
  88. // 必须给足超时;查询类命令下位机立即回复,短超时即可(快速失败、不空等)。
  89. int readTimeout = ReadTimeoutForCmd(frame[1]);
  90. byte[] reply = ReadFixed(replyLen, readTimeout);
  91. if (reply == null)
  92. {
  93. Log?.Invoke($"[{PortName}] 接收超时(期望{replyLen}字节,超时{readTimeout}ms)");
  94. return null;
  95. }
  96. Log?.Invoke($"[{PortName}] 接收: {ToHex(reply)}{(Protocol.CheckChecksum(reply) ? "" : " [校验失败]")}");
  97. // 回复 [n-2] 为下位机结果位,非0视为失败(返回null表示操作失败)
  98. if (replyLen >= 2 && reply[replyLen - 2] != 0)
  99. {
  100. Log?.Invoke($"[{PortName}] 下位机结果位非0(操作失败)");
  101. return null;
  102. }
  103. if (extraWaitMs > 0) Thread.Sleep(extraWaitMs);
  104. return reply;
  105. }
  106. catch (Exception ex)
  107. {
  108. Log?.Invoke($"[{PortName}] 收发异常: {ex.Message}");
  109. return null;
  110. }
  111. }
  112. }
  113. /// <summary>
  114. /// 按命令码返回该命令的读超时(ms)。方案甲核心:
  115. /// 移动类命令(CMD_MOTOR=0x05,含绝对/相对/复位运动)要等机械到位才回复,
  116. /// 回复耗时随移动行程增长(大行程实测 >6 秒),给 MoveReadTimeoutMs(默认12秒);
  117. /// 其余查询/设置类命令(握手0x01/读EEPROM0x11/读位置0x18/设IO0x09等)下位机立即回复,
  118. /// 给 QueryReadTimeoutMs(默认3秒),快速失败不空等。
  119. /// </summary>
  120. private int ReadTimeoutForCmd(byte cmd)
  121. => cmd == Protocol.CMD_MOTOR ? MoveReadTimeoutMs : QueryReadTimeoutMs;
  122. /// <summary>阻塞读取指定字节数,超时返回 null。</summary>
  123. private byte[] ReadFixed(int count, int timeoutMs)
  124. {
  125. byte[] buf = new byte[count];
  126. int got = 0;
  127. var sw = Stopwatch.StartNew();
  128. while (got < count)
  129. {
  130. if (sw.ElapsedMilliseconds > timeoutMs) return null;
  131. try
  132. {
  133. int n = _port.Read(buf, got, count - got);
  134. got += n;
  135. }
  136. catch (TimeoutException)
  137. {
  138. if (sw.ElapsedMilliseconds > timeoutMs) return null;
  139. }
  140. }
  141. return buf;
  142. }
  143. private static string ToHex(byte[] b)
  144. {
  145. if (b == null) return "(null)";
  146. var sb = new System.Text.StringBuilder(b.Length * 3);
  147. foreach (var x in b) sb.Append(x.ToString("X2")).Append(' ');
  148. return sb.ToString().TrimEnd();
  149. }
  150. public void Dispose()
  151. {
  152. Close();
  153. _port?.Dispose();
  154. }
  155. }
  156. }