"""Protocol fidelity: MULTIPART ACK packet layout + RX unwrap. Mirrors upstream MeshCore: - `createMultiAck(crc, remaining)` (src/Mesh.cpp:570-583) - `createAck(crc)` (src/Mesh.cpp:556-568) - Receiver unwrap (src/Mesh.cpp:270-282) """ from __future__ import annotations from orac import meshcore from orac.meshcore import build_ack_packet, build_multiack_packet, parse_header_and_path def _clear_routes() -> None: with meshcore._route_lock: meshcore._route_table.clear() def test_multiack_flood_layout() -> None: """With no learned route, MULTIPART uses flood route_type = 1.""" _clear_routes() crc = bytes([0xDE, 0xAD, 0xBE, 0xEF]) packet = build_multiack_packet(crc, remaining=1, dest_hash=0x42) # Expect: header(1) + path_len(1) + payload(5) # header: PAYLOAD_TYPE_MULTIPART(0x0A) << 2 | ROUTE_TYPE_FLOOD(0x01) # = (0x0A << 2) | 0x01 = 0x29 assert packet[0] == 0x29, f"header byte wrong: {packet[0]:#04x}" # path_len byte: 0x40 = hash_size_code=1 (2-byte), 0 hops assert packet[1] == 0x40 # Payload: (remaining << 4) | 0x03 + crc assert packet[2] == (1 << 4) | 0x03 assert packet[3:7] == crc def test_multiack_direct_layout_with_route() -> None: """With a learned direct route, header uses ROUTE_TYPE_DIRECT = 2.""" _clear_routes() # Seed a 2-byte single-hop route meshcore.learn_route(0x77, [b"\xaa\xbb"], hash_size=2, snr=10.0) crc = bytes([0x01, 0x02, 0x03, 0x04]) packet = build_multiack_packet(crc, remaining=2, dest_hash=0x77) # header = (0x0A << 2) | 0x02 (ROUTE_TYPE_DIRECT) = 0x2A assert packet[0] == 0x2A, f"direct-route header wrong: {packet[0]:#04x}" # path_len byte: hash_size_code=1 (2-byte), 1 hop -> 0x41 assert packet[1] == 0x41 # Path data assert packet[2:4] == b"\xaa\xbb" # Payload: (remaining << 4) | 0x03 + crc assert packet[4] == (2 << 4) | 0x03 assert packet[5:9] == crc def test_multiack_remaining_nibble_bounds() -> None: import pytest with pytest.raises(ValueError): build_multiack_packet(b"\x00\x00\x00\x00", remaining=-1, dest_hash=0x11) with pytest.raises(ValueError): build_multiack_packet(b"\x00\x00\x00\x00", remaining=16, dest_hash=0x11) # 0 and 15 are valid build_multiack_packet(b"\x00\x00\x00\x00", remaining=0, dest_hash=0x11) build_multiack_packet(b"\x00\x00\x00\x00", remaining=15, dest_hash=0x11) def test_multiack_crc_length_enforced() -> None: import pytest with pytest.raises(ValueError): build_multiack_packet(b"\x00\x00\x00", remaining=1, dest_hash=0x11) with pytest.raises(ValueError): build_multiack_packet(b"\x00\x00\x00\x00\x00", remaining=1, dest_hash=0x11) def test_plain_ack_layout_unchanged() -> None: """Regression: plain PAYLOAD_TYPE_ACK (0x03) still works as before.""" _clear_routes() crc = bytes([0xAA, 0xBB, 0xCC, 0xDD]) packet = build_ack_packet(crc, dest_hash=0x99) # header = (0x03 << 2) | 0x01 = 0x0D (flood) assert packet[0] == 0x0D assert packet[1] == 0x40 # Payload is just the 4-byte CRC, no remaining-nibble byte. assert packet[2:6] == crc assert len(packet) == 6 def test_round_trip_parse_extracts_multiack_payload() -> None: """Firmware-style RX: parse_header_and_path strips header+path and hands us the 5-byte MULTIPART payload; our handler expects [nibble|type][crc:4].""" _clear_routes() crc = bytes([0xC0, 0xDE, 0xCA, 0xFE]) packet = build_multiack_packet(crc, remaining=1, dest_hash=0x33) parsed = parse_header_and_path(packet) assert parsed is not None payload_type, route_type, _ver, _pos, payload, _path_hops, _hash_size = parsed assert payload_type == 0x0A # PAYLOAD_TYPE_MULTIPART assert route_type == 0x01 # flood (no route learned) assert len(payload) == 5 assert payload[0] & 0x0F == 0x03 # inner type == PAYLOAD_TYPE_ACK assert payload[0] >> 4 == 1 # remaining = 1 assert payload[1:5] == crc