test_modbus_rtu.py 14 KB

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