test_modbus_rtu.py 11 KB

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