| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- """V4 批量读卡协议 (modbus_rtu) 单元测试。
- 协议 V4: 一次性批量读 24 路卡号。
- 请求: [addr][0x03][0x00][0x02][0x00][0x48][CRC_L][CRC_H] -> 01 03 00 02 00 48 E4 3C
- 响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节
- 每张卡 6 字节; 无卡标记 = FF FF FF FF FF FF
- """
- import sys
- import os
- from unittest.mock import MagicMock
- import pytest
- sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
- from modules.modbus_rtu import ( # noqa: E402
- ModbusRTUClient,
- calculate_crc16,
- verify_crc16,
- CARD_REG_ADDR,
- CARD_REG_QUANTITY,
- CARD_DATA_BYTES,
- BYTES_PER_TAG,
- ANTENNA_COUNT,
- NO_TAG_BYTES,
- BULK_RESPONSE_LEN,
- BULK_BYTE_COUNT,
- ANTENNA_ADDRESSES,
- )
- def build_bulk_response(addr: int, tag_data: bytes) -> bytes:
- """构造合法的 149 字节 V4 批量读响应帧。"""
- assert len(tag_data) == CARD_DATA_BYTES, f"tag_data 必须 {CARD_DATA_BYTES} 字节"
- frame = bytes([addr, 0x03, BULK_BYTE_COUNT]) + tag_data
- return frame + calculate_crc16(frame)
- def build_exception_response(addr: int, exception_code: int) -> bytes:
- """构造 Modbus 异常响应帧 (5 字节)。"""
- frame = bytes([addr, 0x83, exception_code])
- return frame + calculate_crc16(frame)
- class TestReadAllAntennaCards:
- """V4 批量读解析。"""
- def setup_method(self):
- self.client = ModbusRTUClient(MagicMock())
- def _mock_response(self, response_bytes: bytes):
- self.client.serial.ser = MagicMock()
- self.client.serial.send_and_wait.return_value = response_bytes
- def test_request_frame_matches_doc(self):
- """请求帧必须等于文档示例 01 03 00 02 00 48 E4 3C。"""
- self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
- self.client.read_all_antenna_cards(0x01)
- sent = self.client.serial.send_and_wait.call_args[0][0]
- assert sent == bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48, 0xE4, 0x3C])
- def test_min_response_bytes_is_full_frame(self):
- """必须要求完整 149 字节,避免截断。"""
- self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
- self.client.read_all_antenna_cards(0x01)
- kwargs = self.client.serial.send_and_wait.call_args.kwargs
- assert kwargs.get('min_response_bytes') == BULK_RESPONSE_LEN
- def test_no_tag_response(self):
- """24 路全无卡 (FF*144)。CRC 动态计算,不用文档笔误的 c9a2。"""
- tag_data = NO_TAG_BYTES * ANTENNA_COUNT # 144 字节 0xFF
- self._mock_response(build_bulk_response(0x01, tag_data))
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' not in result
- assert result['success'] is True
- assert result['device_address'] == 0x01
- assert result['function_code'] == 0x03
- assert len(result['cards']) == ANTENNA_COUNT
- for idx, card in enumerate(result['cards']):
- assert card['antenna'] == idx + 1
- assert card['present'] is False
- assert card['card_str'] == ''
- assert card['uid'] == ''
- def test_tag_on_antenna_24(self):
- """文档示例: 24 号天线有卡 ED9A57F95001,其余无卡。"""
- tag = bytes([0xED, 0x9A, 0x57, 0xF9, 0x50, 0x01])
- tag_data = NO_TAG_BYTES * 23 + tag # 138 + 6 = 144
- self._mock_response(build_bulk_response(0x03, tag_data))
- result = self.client.read_all_antenna_cards(0x03)
- assert 'error' not in result
- cards = result['cards']
- assert len(cards) == 24
- for i in range(23):
- assert cards[i]['present'] is False
- assert cards[23]['antenna'] == 24
- assert cards[23]['present'] is True
- assert cards[23]['card_str'] == 'ed9a57f95001'
- assert cards[23]['uid'] == 'ed:9a:57:f9:50:01'
- def test_tag_on_antenna_1(self):
- """1 号天线有卡,其余无卡。"""
- tag = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
- tag_data = tag + NO_TAG_BYTES * 23
- self._mock_response(build_bulk_response(0x01, tag_data))
- result = self.client.read_all_antenna_cards(0x01)
- assert result['cards'][0]['present'] is True
- assert result['cards'][0]['card_str'] == 'aabbccddeeff'
- assert result['cards'][0]['uid'] == 'aa:bb:cc:dd:ee:ff'
- for i in range(1, 24):
- assert result['cards'][i]['present'] is False
- def test_multiple_tags(self):
- """1/12/24 号天线同时有卡。"""
- tag1 = bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])
- tag12 = bytes([0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC])
- tag24 = bytes([0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22])
- tag_data = bytearray(NO_TAG_BYTES * ANTENNA_COUNT)
- tag_data[0:6] = tag1
- tag_data[11 * 6:12 * 6] = tag12
- tag_data[23 * 6:24 * 6] = tag24
- self._mock_response(build_bulk_response(0x01, bytes(tag_data)))
- result = self.client.read_all_antenna_cards(0x01)
- assert result['cards'][0]['card_str'] == '112233445566'
- assert result['cards'][11]['card_str'] == '778899aabbcc'
- assert result['cards'][23]['card_str'] == 'ddeeff001122'
- assert result['cards'][1]['present'] is False
- def test_crc_failure(self):
- """CRC 篡改 -> error。"""
- response = build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT)
- response = response[:-1] + bytes([response[-1] ^ 0xFF]) # 改 CRC 末字节
- self._mock_response(response)
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- assert 'CRC' in result['error']
- def test_short_frame(self):
- """截断响应 (<149) -> error。"""
- self._mock_response(bytes([0x01, 0x03, 0x90] + [0xFF] * 7))
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- def test_exception_response(self):
- """Modbus 异常帧 (0x83) -> error 含 exception。"""
- self._mock_response(build_exception_response(0x01, 0x02))
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- assert 'exception' in result['error'].lower()
- def test_unexpected_function_code(self):
- """非 0x03 响应 -> error。"""
- frame = bytes([0x01, 0x04, BULK_BYTE_COUNT]) + NO_TAG_BYTES * ANTENNA_COUNT
- self._mock_response(frame + calculate_crc16(frame))
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- def test_serial_not_connected(self):
- """串口未连接 -> error。"""
- self.client.serial.ser = None
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- def test_empty_response(self):
- """空响应 -> error。"""
- self._mock_response(b'')
- result = self.client.read_all_antenna_cards(0x01)
- assert 'error' in result
- class TestReadAntennaCardSingle:
- """单卡提取 (向后兼容包装)。"""
- def setup_method(self):
- self.client = ModbusRTUClient(MagicMock())
- self.client.serial.ser = MagicMock()
- def test_extract_antenna_1_with_tag(self):
- tag = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01])
- tag_data = tag + NO_TAG_BYTES * 23
- self.client.serial.send_and_wait.return_value = build_bulk_response(0x01, tag_data)
- result = self.client.read_antenna_card(0x01, 1)
- assert 'error' not in result
- assert result['antenna'] == 1
- assert result['card_number_str'] == 'deadbeef0001'
- assert result['uid'] == 'de:ad:be:ef:00:01'
- assert result['card_number'] == int.from_bytes(tag, 'big')
- assert result['card_number_hex'] == f'0x{int.from_bytes(tag, "big"):012x}'
- def test_extract_antenna_no_tag(self):
- """无卡时 card_number_str 为空串 (V2 是 16 个 0)。"""
- self.client.serial.send_and_wait.return_value = build_bulk_response(
- 0x01, NO_TAG_BYTES * 24)
- result = self.client.read_antenna_card(0x01, 5)
- assert 'error' not in result
- assert result['antenna'] == 5
- assert result['card_number_str'] == ''
- assert result['uid'] == ''
- def test_extract_antenna_24_with_tag(self):
- tag = bytes([0xED, 0x9A, 0x57, 0xF9, 0x50, 0x01])
- tag_data = NO_TAG_BYTES * 23 + tag
- self.client.serial.send_and_wait.return_value = build_bulk_response(0x03, tag_data)
- result = self.client.read_antenna_card(0x03, 24)
- assert 'error' not in result
- assert result['antenna'] == 24
- assert result['card_number_str'] == 'ed9a57f95001'
- def test_invalid_antenna_number(self):
- result = self.client.read_antenna_card(0x01, 25)
- assert 'error' in result
- result = self.client.read_antenna_card(0x01, 0)
- assert 'error' in result
- def test_bulk_error_propagates(self):
- """批量读失败时,单卡读透传 error。"""
- self.client.serial.send_and_wait.return_value = b''
- result = self.client.read_antenna_card(0x01, 1)
- assert 'error' in result
- def test_does_not_send_v2_frame(self):
- """单卡读不得发 V2 逐天线帧 (0x0002+(n-1)*4)。"""
- self.client.serial.send_and_wait.return_value = build_bulk_response(
- 0x01, NO_TAG_BYTES * 24)
- self.client.read_antenna_card(0x01, 10)
- sent = self.client.serial.send_and_wait.call_args[0][0]
- # 必须是 V4 批量帧 01 03 00 02 00 48 ...
- assert sent[1] == 0x03
- assert sent[2:4] == bytes([0x00, 0x02])
- assert sent[4:6] == bytes([0x00, 0x48])
- # V2 第 10 天线会是 0x0026,不得出现
- assert sent[2:4] != bytes([0x00, 0x26])
- class TestAntennaAddressesMapping:
- """V4 天线地址映射。"""
- def test_all_antennas_share_card_register(self):
- """V4 下所有天线共享 0x0002。"""
- for ant in range(1, 25):
- assert ANTENNA_ADDRESSES[ant] == CARD_REG_ADDR
- def test_no_v2_per_antenna_addresses(self):
- """不得残留 V2 的逐天线地址 (0x0006/0x000A/...)。"""
- for ant in range(1, 25):
- assert ANTENNA_ADDRESSES[ant] != 0x0002 + (ant - 1) * 4 or ant == 1
- class TestCrcHelpers:
- """CRC 辅助函数 (回归)。"""
- def test_request_crc_matches_doc(self):
- """请求帧 CRC = E4 3C (文档已核实)。"""
- req = bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48])
- assert calculate_crc16(req) == bytes([0xE4, 0x3C])
- def test_no_tag_crc_is_not_doc_typo(self):
- """无标签响应 CRC 实为 8F 44,文档的 C9 A2 是笔误。"""
- frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
- assert calculate_crc16(frame) == bytes([0x8F, 0x44])
- def test_verify_crc16_valid_frame(self):
- frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
- assert verify_crc16(frame + calculate_crc16(frame)) is True
- def test_verify_crc16_corrupted(self):
- frame = bytes([0x01, 0x03, 0x90]) + b'\xff' * 144
- crc = bytearray(calculate_crc16(frame))
- crc[0] ^= 0xFF
- assert verify_crc16(frame + bytes(crc)) is False
- if __name__ == '__main__':
- pytest.main([__file__, '-v'])
|