"""Mock tests for HID USB protocol (Type 2 and Type 3 devices). No real USB hardware required — all USB I/O is mocked via UsbTransport. """ import struct from unittest.mock import MagicMock, call, patch import pytest from tests.adapters.device.conftest import make_mock_transport as _make_mock_transport from trcc.adapters.device.hid import ( DELAY_FRAME_TYPE2_S, DELAY_POST_INIT_S, DELAY_PRE_INIT_S, EP_READ_01, EP_WRITE_02, HIDAPI_AVAILABLE, PYUSB_AVAILABLE, TYPE2_INIT_SIZE, TYPE2_MAGIC, TYPE2_PID, TYPE2_RESPONSE_SIZE, TYPE2_VID, TYPE3_ACK_SIZE, TYPE3_CMD_PREFIX, TYPE3_DATA_SIZE, TYPE3_FRAME_PREFIX, TYPE3_FRAME_TOTAL, TYPE3_INIT_SIZE, TYPE3_PID, TYPE3_RESPONSE_SIZE, TYPE3_VID, USB_BULK_ALIGNMENT, USB_CONFIGURATION, USB_INTERFACE, HidDeviceManager, HidDeviceType2, HidDeviceType3, HidHandshakeInfo, _ceil_to_512, find_hid_devices, ) def _make_type2_valid_response() -> bytes: """Build a valid Type 2 handshake response (512 bytes).""" resp = bytearray(TYPE2_RESPONSE_SIZE) resp[0:4] = TYPE2_MAGIC # magic resp[4] = 0x02 # mode byte 1 resp[5] = 0x03 # mode byte 2 resp[12] = 0x01 # must be 1 resp[16] = 0x10 # must be 0x10 resp[20:36] = b'\xAB\xCD\xEF\x01\x23\x45\x67\x89' * 2 # serial return bytes(resp) def _make_type3_valid_response(first_byte: int = 0x65) -> bytes: """Build a valid Type 3 handshake response (1024 bytes).""" resp = bytearray(TYPE3_RESPONSE_SIZE) resp[0] = first_byte # 0x65 or 0x66 resp[10:14] = b'\xDE\xAD\xBE\xEF' # serial return bytes(resp) # ========================================================================= # _ceil_to_512 # ========================================================================= class TestCeilTo512: """Test the 512-byte alignment helper.""" def test_zero(self): assert _ceil_to_512(0) == 0 def test_exact_multiple(self): assert _ceil_to_512(512) == 512 assert _ceil_to_512(1024) == 1024 def test_one_byte_over(self): assert _ceil_to_512(513) == 1024 def test_one_byte_under(self): assert _ceil_to_512(511) == 512 def test_small_value(self): assert _ceil_to_512(1) == 512 assert _ceil_to_512(100) == 512 def test_large_value(self): assert _ceil_to_512(10000) == 10240 # 20 * 512 def test_matches_csharp_formula(self): """Verify our impl matches the C# formula for a range of values.""" for n in range(0, 2049): expected = (n // 512) * 512 + (512 if n % 512 != 0 else 0) assert _ceil_to_512(n) == expected, f"Failed for n={n}" # ========================================================================= # Type 2 — Init Packet # ========================================================================= class TestType2InitPacket: """Test HidDeviceType2.build_init_packet() exact bytes.""" def test_packet_length(self): pkt = HidDeviceType2.build_init_packet() assert len(pkt) == TYPE2_INIT_SIZE # 512 def test_magic_bytes(self): pkt = HidDeviceType2.build_init_packet() assert pkt[0:4] == bytes([0xDA, 0xDB, 0xDC, 0xDD]) def test_command_byte(self): pkt = HidDeviceType2.build_init_packet() assert pkt[12] == 0x01 # command = 1 def test_reserved_zeros(self): pkt = HidDeviceType2.build_init_packet() # bytes 4-11 = zeros assert pkt[4:12] == b'\x00' * 8 # bytes 13-19 = zeros assert pkt[13:20] == b'\x00' * 7 # rest = zeros assert pkt[20:] == b'\x00' * 492 def test_byte_by_byte_match(self): """Verify the first 20 bytes match the C# array literal exactly.""" pkt = HidDeviceType2.build_init_packet() expected = bytes([ 0xDA, 0xDB, 0xDC, 0xDD, # magic 0, 0, 0, 0, # zeros 0, 0, 0, 0, # zeros 1, 0, 0, 0, # command = 1 0, 0, 0, 0, # zeros ]) assert pkt[:20] == expected # ========================================================================= # Type 2 — Response Validation # ========================================================================= class TestType2ResponseValidation: """Test HidDeviceType2.validate_response().""" def test_valid_response(self): resp = _make_type2_valid_response() assert HidDeviceType2.validate_response(resp) is True def test_wrong_magic_byte_0(self): resp = bytearray(_make_type2_valid_response()) resp[0] = 0xFF assert HidDeviceType2.validate_response(bytes(resp)) is False def test_wrong_magic_byte_3(self): resp = bytearray(_make_type2_valid_response()) resp[3] = 0x00 assert HidDeviceType2.validate_response(bytes(resp)) is False def test_wrong_command_byte(self): resp = bytearray(_make_type2_valid_response()) resp[12] = 0x00 # must be 1 assert HidDeviceType2.validate_response(bytes(resp)) is False def test_type_byte_not_checked(self): """resp[16] is NOT part of validation (only used for serial extraction).""" resp = bytearray(_make_type2_valid_response()) resp[16] = 0x00 # was 0x10, but validation doesn't check it assert HidDeviceType2.validate_response(bytes(resp)) is True def test_short_response(self): assert HidDeviceType2.validate_response(b'\xDA\xDB\xDC\xDD') is False def test_empty_response(self): assert HidDeviceType2.validate_response(b'') is False # ========================================================================= # Type 2 — Device Info Parsing # ========================================================================= class TestType2DeviceInfo: """Test HidDeviceType2.parse_device_info().""" def test_basic_extraction(self): resp = _make_type2_valid_response() info = HidDeviceType2.parse_device_info(resp) assert info.device_type == 2 # PM = resp[5] = 0x03, SUB = resp[4] = 0x02 assert info.mode_byte_1 == 0x03 assert info.mode_byte_2 == 0x02 def test_serial_extraction(self): resp = bytearray(TYPE2_RESPONSE_SIZE) resp[0:4] = TYPE2_MAGIC resp[16] = 0x10 # Serial marker byte (must be 0x10 for serial extraction) resp[20:36] = bytes(range(16)) # 0x00..0x0F info = HidDeviceType2.parse_device_info(bytes(resp)) assert info.serial == "000102030405060708090A0B0C0D0E0F" def test_serial_absent_without_marker(self): """Serial is empty when resp[16] != 0x10.""" resp = bytearray(TYPE2_RESPONSE_SIZE) resp[0:4] = TYPE2_MAGIC resp[16] = 0x00 # No serial marker resp[20:36] = bytes(range(16)) info = HidDeviceType2.parse_device_info(bytes(resp)) assert info.serial == "" def test_fbl_from_pm(self): """Type 2 resolves FBL from PM byte via pm_to_fbl().""" resp = _make_type2_valid_response() info = HidDeviceType2.parse_device_info(resp) # PM=resp[5]=0x03, default PM=FBL, FBL=3 not in table → (320, 320) assert info.fbl == 3 assert info.resolution == (320, 320) # ========================================================================= # Type 2 — Handshake # ========================================================================= class TestType2Handshake: """Test HidDeviceType2.handshake() orchestration.""" def test_successful_handshake(self): transport = _make_mock_transport() transport.read.return_value = _make_type2_valid_response() transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) info = dev.handshake() assert info.device_type == 2 assert dev._initialized is True transport.write.assert_called_once() transport.read.assert_called_once() def test_handshake_sends_correct_packet(self): transport = _make_mock_transport() transport.read.return_value = _make_type2_valid_response() transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) dev.handshake() write_args = transport.write.call_args assert write_args[0][0] == EP_WRITE_02 # endpoint assert len(write_args[0][1]) == TYPE2_INIT_SIZE # packet size def test_handshake_reads_from_ep01(self): transport = _make_mock_transport() transport.read.return_value = _make_type2_valid_response() transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) dev.handshake() read_args = transport.read.call_args assert read_args[0][0] == EP_READ_01 assert read_args[0][1] == TYPE2_RESPONSE_SIZE def test_handshake_failure_raises(self): transport = _make_mock_transport() transport.read.return_value = b'\x00' * TYPE2_RESPONSE_SIZE # bad magic dev = HidDeviceType2(transport) with pytest.raises(RuntimeError, match="handshake failed"): dev.handshake() assert dev._initialized is False def test_handshake_stores_device_info(self): transport = _make_mock_transport() resp = bytearray(_make_type2_valid_response()) resp[4] = 0x42 # SUB byte resp[5] = 0x99 # PM byte transport.read.return_value = bytes(resp) transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) info = dev.handshake() assert dev.device_info is info # PM = resp[5], SUB = resp[4] assert info.mode_byte_1 == 0x99 assert info.mode_byte_2 == 0x42 def test_handshake_pm_byte_and_sub_byte(self): """Type 2: pm_byte/sub_byte carry raw PM+SUB for button image (#69).""" transport = _make_mock_transport() resp = bytearray(_make_type2_valid_response()) resp[4] = 1 # SUB resp[5] = 7 # PM transport.read.return_value = bytes(resp) transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) info = dev.handshake() assert info.pm_byte == 7 assert info.sub_byte == 1 def test_handshake_timing(self): """Verify C# Sleep(50) + Sleep(200) timing is called.""" transport = _make_mock_transport() transport.read.return_value = _make_type2_valid_response() transport.write.return_value = TYPE2_INIT_SIZE dev = HidDeviceType2(transport) with patch("trcc.adapters.device.hid.time.sleep") as mock_sleep: dev.handshake() # Should call sleep(0.05) then sleep(0.2) calls = mock_sleep.call_args_list assert len(calls) == 2 assert calls[0] == call(DELAY_PRE_INIT_S) assert calls[1] == call(DELAY_POST_INIT_S) # ========================================================================= # Type 2 — Frame Send # ========================================================================= class TestType2FrameSend: """Test HidDeviceType2 frame construction and sending.""" def _init_device(self) -> tuple: """Helper: create initialized Type 2 device.""" transport = _make_mock_transport() transport.read.return_value = _make_type2_valid_response() transport.write.return_value = 512 dev = HidDeviceType2(transport) dev.handshake() transport.reset_mock() transport.write.return_value = 512 return dev, transport def test_frame_packet_magic(self): """Verify DA DB DC DD magic in frame header (C# FormCZTV mode 3).""" pkt = HidDeviceType2.build_frame_packet(b'\xFF' * 100) assert pkt[0:4] == b'\xDA\xDB\xDC\xDD' def test_frame_packet_header(self): """Verify 20-byte header matches C# FormCZTV.ImageTo565() mode 3.""" data = b'\xFF' * 1000 pkt = HidDeviceType2.build_frame_packet(data) # [0:4] = DA DB DC DD magic assert pkt[0:4] == b'\xDA\xDB\xDC\xDD' # [4] = 0x02 (SSCRM_CMD_TYPE_PICTURE) assert pkt[4] == 0x02 # [6] = 0x01 (mode flag) assert pkt[6] == 0x01 # [8:12] = hardcoded 240, 320 (LE16) assert pkt[8:10] == b'\xF0\x00' # 240 assert pkt[10:12] == b'\x40\x01' # 320 # [12] = 0x02 (sub-flag) assert pkt[12] == 0x02 # [16:20] = LE uint32 of data length size_field = struct.unpack(' tuple: """Helper: create initialized Type 3 device.""" transport = _make_mock_transport() transport.read.return_value = _make_type3_valid_response() transport.write.return_value = TYPE3_INIT_SIZE dev = HidDeviceType3(transport) dev.handshake() transport.reset_mock() transport.write.return_value = TYPE3_FRAME_TOTAL transport.read.return_value = b'\x00' * TYPE3_ACK_SIZE return dev, transport def test_frame_packet_total_length(self): pkt = HidDeviceType3.build_frame_packet(b'\xFF' * 1000) assert len(pkt) == TYPE3_FRAME_TOTAL # 204816 def test_frame_packet_prefix(self): pkt = HidDeviceType3.build_frame_packet(b'\xFF' * 100) expected_prefix = bytes([ 0xF5, 0x01, 0x01, 0x00, 0xBC, 0xFF, 0xB6, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x03, 0x00, # 204800 LE ]) assert pkt[:16] == expected_prefix def test_frame_packet_size_field(self): """bytes[12:16] should encode 204800 as LE uint32.""" pkt = HidDeviceType3.build_frame_packet(b'') size = struct.unpack('= 1 assert devices[0]['backend'] == 'pyusb' def test_no_backends_returns_empty(self): """Returns empty list when neither pyusb nor hidapi available.""" import trcc.adapters.device.hid as mod orig_pyusb = mod.PYUSB_AVAILABLE orig_hidapi = mod.HIDAPI_AVAILABLE mod.PYUSB_AVAILABLE = False mod.HIDAPI_AVAILABLE = False try: devices = find_hid_devices() assert devices == [] finally: mod.PYUSB_AVAILABLE = orig_pyusb mod.HIDAPI_AVAILABLE = orig_hidapi # ========================================================================= # HidHandshakeInfo dataclass # ========================================================================= class TestDeviceInfo: """Test HidHandshakeInfo dataclass fields and defaults.""" def test_defaults(self): info = HidHandshakeInfo(device_type=2) assert info.device_type == 2 assert info.mode_byte_1 == 0 assert info.mode_byte_2 == 0 assert info.serial == "" assert info.fbl is None def test_type3_with_fbl(self): info = HidHandshakeInfo(device_type=3, fbl=100, serial="DEADBEEF") assert info.fbl == 100 assert info.serial == "DEADBEEF" # ========================================================================= # Constants sanity checks # ========================================================================= class TestConstants: """Verify constants match the C# source values.""" def test_type2_vid_pid(self): assert TYPE2_VID == 0x0416 assert TYPE2_PID == 0x5302 def test_type3_vid_pid(self): assert TYPE3_VID == 0x0418 assert TYPE3_PID == 0x5303 def test_type2_magic(self): assert TYPE2_MAGIC == bytes([0xDA, 0xDB, 0xDC, 0xDD]) def test_type3_cmd_prefix(self): assert TYPE3_CMD_PREFIX == bytes([0xF5, 0x00, 0x01, 0x00, 0xBC, 0xFF, 0xB6, 0xC8]) def test_type3_frame_prefix(self): assert TYPE3_FRAME_PREFIX == bytes([0xF5, 0x01, 0x01, 0x00, 0xBC, 0xFF, 0xB6, 0xC8]) def test_type3_data_size(self): assert TYPE3_DATA_SIZE == 320 * 320 * 2 # RGB565 def test_type3_frame_total(self): assert TYPE3_FRAME_TOTAL == 16 + TYPE3_DATA_SIZE def test_type3_init_size(self): assert TYPE3_INIT_SIZE == 16 + 1024 def test_timing_constants(self): """Verify timing matches C# Thread.Sleep values.""" assert DELAY_PRE_INIT_S == 0.050 # Sleep(50) assert DELAY_POST_INIT_S == 0.200 # Sleep(200) assert DELAY_FRAME_TYPE2_S == 0.001 # Sleep(1) def test_usb_config(self): """Verify USB config matches C# SetConfiguration/ClaimInterface.""" assert USB_CONFIGURATION == 1 assert USB_INTERFACE == 0