test_modbus_rtu.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """V4 批量读卡协议 (modbus_rtu) 单元测试。
  2. 协议 V4: 一次性批量读 24 路卡号。
  3. 请求: [addr][0x03][0x00][0x02][0x00][0x48][CRC_L][CRC_H] -> 01 03 00 02 00 48 E4 3C
  4. 响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节
  5. 每张卡 6 字节; 无卡标记 = FF FF FF FF FF FF
  6. """
  7. import sys
  8. import os
  9. from unittest.mock import MagicMock
  10. import pytest
  11. sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  12. from modules.modbus_rtu import ( # noqa: E402
  13. ModbusRTUClient,
  14. calculate_crc16,
  15. verify_crc16,
  16. CARD_REG_ADDR,
  17. CARD_REG_QUANTITY,
  18. CARD_DATA_BYTES,
  19. BYTES_PER_TAG,
  20. ANTENNA_COUNT,
  21. NO_TAG_BYTES,
  22. BULK_RESPONSE_LEN,
  23. BULK_BYTE_COUNT,
  24. ANTENNA_ADDRESSES,
  25. )
  26. def build_bulk_response(addr: int, tag_data: bytes) -> bytes:
  27. """构造合法的 149 字节 V4 批量读响应帧。"""
  28. assert len(tag_data) == CARD_DATA_BYTES, f"tag_data 必须 {CARD_DATA_BYTES} 字节"
  29. frame = bytes([addr, 0x03, BULK_BYTE_COUNT]) + tag_data
  30. return frame + calculate_crc16(frame)
  31. def build_exception_response(addr: int, exception_code: int) -> bytes:
  32. """构造 Modbus 异常响应帧 (5 字节)。"""
  33. frame = bytes([addr, 0x83, exception_code])
  34. return frame + calculate_crc16(frame)
  35. class TestReadAllAntennaCards:
  36. """V4 批量读解析。"""
  37. def setup_method(self):
  38. self.client = ModbusRTUClient(MagicMock())
  39. def _mock_response(self, response_bytes: bytes):
  40. self.client.serial.ser = MagicMock()
  41. self.client.serial.send_and_wait.return_value = response_bytes
  42. def test_request_frame_matches_doc(self):
  43. """请求帧必须等于文档示例 01 03 00 02 00 48 E4 3C。"""
  44. self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
  45. self.client.read_all_antenna_cards(0x01)
  46. sent = self.client.serial.send_and_wait.call_args[0][0]
  47. assert sent == bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48, 0xE4, 0x3C])
  48. def test_min_response_bytes_is_full_frame(self):
  49. """必须要求完整 149 字节,避免截断。"""
  50. self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
  51. self.client.read_all_antenna_cards(0x01)
  52. kwargs = self.client.serial.send_and_wait.call_args.kwargs
  53. assert kwargs.get('min_response_bytes') == BULK_RESPONSE_LEN
  54. def test_no_tag_response(self):
  55. """24 路全无卡 (FF*144)。CRC 动态计算,不用文档笔误的 c9a2。"""
  56. tag_data = NO_TAG_BYTES * ANTENNA_COUNT # 144 字节 0xFF
  57. self._mock_response(build_bulk_response(0x01, tag_data))
  58. result = self.client.read_all_antenna_cards(0x01)
  59. assert 'error' not in result
  60. assert result['success'] is True
  61. assert result['device_address'] == 0x01
  62. assert result['function_code'] == 0x03
  63. assert len(result['cards']) == ANTENNA_COUNT
  64. for idx, card in enumerate(result['cards']):
  65. assert card['antenna'] == idx + 1
  66. assert card['present'] is False
  67. assert card['card_str'] == ''
  68. assert card['uid'] == ''
  69. def test_tag_on_antenna_24(self):
  70. """文档示例: 24 号天线有卡 ED9A57F95001,其余无卡。"""
  71. tag = bytes([0xED, 0x9A, 0x57, 0xF9, 0x50, 0x01])
  72. tag_data = NO_TAG_BYTES * 23 + tag # 138 + 6 = 144
  73. self._mock_response(build_bulk_response(0x03, tag_data))
  74. result = self.client.read_all_antenna_cards(0x03)
  75. assert 'error' not in result
  76. cards = result['cards']
  77. assert len(cards) == 24
  78. for i in range(23):
  79. assert cards[i]['present'] is False
  80. assert cards[23]['antenna'] == 24
  81. assert cards[23]['present'] is True
  82. assert cards[23]['card_str'] == 'ed9a57f95001'
  83. assert cards[23]['uid'] == 'ed:9a:57:f9:50:01'
  84. def test_tag_on_antenna_1(self):
  85. """1 号天线有卡,其余无卡。"""
  86. tag = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
  87. tag_data = tag + NO_TAG_BYTES * 23
  88. self._mock_response(build_bulk_response(0x01, tag_data))
  89. result = self.client.read_all_antenna_cards(0x01)
  90. assert result['cards'][0]['present'] is True
  91. assert result['cards'][0]['card_str'] == 'aabbccddeeff'
  92. assert result['cards'][0]['uid'] == 'aa:bb:cc:dd:ee:ff'
  93. for i in range(1, 24):
  94. assert result['cards'][i]['present'] is False
  95. def test_multiple_tags(self):
  96. """1/12/24 号天线同时有卡。"""
  97. tag1 = bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])
  98. tag12 = bytes([0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC])
  99. tag24 = bytes([0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22])
  100. tag_data = bytearray(NO_TAG_BYTES * ANTENNA_COUNT)
  101. tag_data[0:6] = tag1
  102. tag_data[11 * 6:12 * 6] = tag12
  103. tag_data[23 * 6:24 * 6] = tag24
  104. self._mock_response(build_bulk_response(0x01, bytes(tag_data)))
  105. result = self.client.read_all_antenna_cards(0x01)
  106. assert result['cards'][0]['card_str'] == '112233445566'
  107. assert result['cards'][11]['card_str'] == '778899aabbcc'
  108. assert result['cards'][23]['card_str'] == 'ddeeff001122'
  109. assert result['cards'][1]['present'] is False
  110. def test_crc_failure(self):
  111. """CRC 篡改 -> error。"""
  112. response = build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT)
  113. response = response[:-1] + bytes([response[-1] ^ 0xFF]) # 改 CRC 末字节
  114. self._mock_response(response)
  115. result = self.client.read_all_antenna_cards(0x01)
  116. assert 'error' in result
  117. assert 'CRC' in result['error']
  118. def test_short_frame(self):
  119. """截断响应 (<149) -> error。"""
  120. self._mock_response(bytes([0x01, 0x03, 0x90] + [0xFF] * 7))
  121. result = self.client.read_all_antenna_cards(0x01)
  122. assert 'error' in result
  123. def test_exception_response(self):
  124. """Modbus 异常帧 (0x83) -> error 含 exception。"""
  125. self._mock_response(build_exception_response(0x01, 0x02))
  126. result = self.client.read_all_antenna_cards(0x01)
  127. assert 'error' in result
  128. assert 'exception' in result['error'].lower()
  129. def test_unexpected_function_code(self):
  130. """非 0x03 响应 -> error。"""
  131. frame = bytes([0x01, 0x04, BULK_BYTE_COUNT]) + NO_TAG_BYTES * ANTENNA_COUNT
  132. self._mock_response(frame + calculate_crc16(frame))
  133. result = self.client.read_all_antenna_cards(0x01)
  134. assert 'error' in result
  135. def test_serial_not_connected(self):
  136. """串口未连接 -> error。"""
  137. self.client.serial.ser = None
  138. result = self.client.read_all_antenna_cards(0x01)
  139. assert 'error' in result
  140. def test_empty_response(self):
  141. """空响应 -> error。"""
  142. self._mock_response(b'')
  143. result = self.client.read_all_antenna_cards(0x01)
  144. assert 'error' in result
  145. class TestReadAntennaCardSingle:
  146. """单卡提取 (向后兼容包装)。"""
  147. def setup_method(self):
  148. self.client = ModbusRTUClient(MagicMock())
  149. self.client.serial.ser = MagicMock()
  150. def test_extract_antenna_1_with_tag(self):
  151. tag = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01])
  152. tag_data = tag + NO_TAG_BYTES * 23
  153. self.client.serial.send_and_wait.return_value = build_bulk_response(0x01, tag_data)
  154. result = self.client.read_antenna_card(0x01, 1)
  155. assert 'error' not in result
  156. assert result['antenna'] == 1
  157. assert result['card_number_str'] == 'deadbeef0001'
  158. assert result['uid'] == 'de:ad:be:ef:00:01'
  159. assert result['card_number'] == int.from_bytes(tag, 'big')
  160. assert result['card_number_hex'] == f'0x{int.from_bytes(tag, "big"):012x}'
  161. def test_extract_antenna_no_tag(self):
  162. """无卡时 card_number_str 为空串 (V2 是 16 个 0)。"""
  163. self.client.serial.send_and_wait.return_value = build_bulk_response(
  164. 0x01, NO_TAG_BYTES * 24)
  165. result = self.client.read_antenna_card(0x01, 5)
  166. assert 'error' not in result
  167. assert result['antenna'] == 5
  168. assert result['card_number_str'] == ''
  169. assert result['uid'] == ''
  170. def test_extract_antenna_24_with_tag(self):
  171. tag = bytes([0xED, 0x9A, 0x57, 0xF9, 0x50, 0x01])
  172. tag_data = NO_TAG_BYTES * 23 + tag
  173. self.client.serial.send_and_wait.return_value = build_bulk_response(0x03, tag_data)
  174. result = self.client.read_antenna_card(0x03, 24)
  175. assert 'error' not in result
  176. assert result['antenna'] == 24
  177. assert result['card_number_str'] == 'ed9a57f95001'
  178. def test_invalid_antenna_number(self):
  179. result = self.client.read_antenna_card(0x01, 25)
  180. assert 'error' in result
  181. result = self.client.read_antenna_card(0x01, 0)
  182. assert 'error' in result
  183. def test_bulk_error_propagates(self):
  184. """批量读失败时,单卡读透传 error。"""
  185. self.client.serial.send_and_wait.return_value = b''
  186. result = self.client.read_antenna_card(0x01, 1)
  187. assert 'error' in result
  188. def test_does_not_send_v2_frame(self):
  189. """单卡读不得发 V2 逐天线帧 (0x0002+(n-1)*4)。"""
  190. self.client.serial.send_and_wait.return_value = build_bulk_response(
  191. 0x01, NO_TAG_BYTES * 24)
  192. self.client.read_antenna_card(0x01, 10)
  193. sent = self.client.serial.send_and_wait.call_args[0][0]
  194. # 必须是 V4 批量帧 01 03 00 02 00 48 ...
  195. assert sent[1] == 0x03
  196. assert sent[2:4] == bytes([0x00, 0x02])
  197. assert sent[4:6] == bytes([0x00, 0x48])
  198. # V2 第 10 天线会是 0x0026,不得出现
  199. assert sent[2:4] != bytes([0x00, 0x26])
  200. class TestAntennaAddressesMapping:
  201. """V4 天线地址映射。"""
  202. def test_all_antennas_share_card_register(self):
  203. """V4 下所有天线共享 0x0002。"""
  204. for ant in range(1, 25):
  205. assert ANTENNA_ADDRESSES[ant] == CARD_REG_ADDR
  206. def test_no_v2_per_antenna_addresses(self):
  207. """不得残留 V2 的逐天线地址 (0x0006/0x000A/...)。"""
  208. for ant in range(1, 25):
  209. assert ANTENNA_ADDRESSES[ant] != 0x0002 + (ant - 1) * 4 or ant == 1
  210. class TestCrcHelpers:
  211. """CRC 辅助函数 (回归)。"""
  212. def test_request_crc_matches_doc(self):
  213. """请求帧 CRC = E4 3C (文档已核实)。"""
  214. req = bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48])
  215. assert calculate_crc16(req) == bytes([0xE4, 0x3C])
  216. def test_no_tag_crc_is_not_doc_typo(self):
  217. """无标签响应 CRC 实为 8F 44,文档的 C9 A2 是笔误。"""
  218. frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
  219. assert calculate_crc16(frame) == bytes([0x8F, 0x44])
  220. def test_verify_crc16_valid_frame(self):
  221. frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
  222. assert verify_crc16(frame + calculate_crc16(frame)) is True
  223. def test_verify_crc16_corrupted(self):
  224. frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
  225. crc = bytearray(calculate_crc16(frame))
  226. crc[0] ^= 0xFF
  227. assert verify_crc16(frame + bytes(crc)) is False
  228. if __name__ == '__main__':
  229. pytest.main([__file__, '-v'])