""" test_jlink_frames.py — Issue #120: JLink binary protocol unit tests. Tests CRC16-XModem, frame building, frame parsing (state machine), command payload encoding, and telemetry frame layout. No HAL or ESP32-S3 hardware required — pure Python logic. """ import struct import pytest # --------------------------------------------------------------------------- # CRC16-XModem (poly 0x1021, init 0x0000) — mirrors crc16_xmodem() in jlink.c # --------------------------------------------------------------------------- def crc16_xmodem(data: bytes) -> int: crc = 0x0000 for byte in data: crc ^= byte << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF return crc # --------------------------------------------------------------------------- # Frame builder — mirrors jlink_send_telemetry() structure # --------------------------------------------------------------------------- JLINK_STX = 0x02 JLINK_ETX = 0x03 JLINK_CMD_HEARTBEAT = 0x01 JLINK_CMD_DRIVE = 0x02 JLINK_CMD_ARM = 0x03 JLINK_CMD_DISARM = 0x04 JLINK_CMD_PID_SET = 0x05 JLINK_CMD_ESTOP = 0x07 JLINK_TLM_STATUS = 0x80 def build_frame(cmd: int, payload: bytes = b"") -> bytes: """Build a JLink frame: [STX][LEN][CMD][PAYLOAD][CRC_hi][CRC_lo][ETX]""" body = bytes([cmd]) + payload length = len(body) crc = crc16_xmodem(body) return bytes([JLINK_STX, length, cmd]) + payload + bytes([crc >> 8, crc & 0xFF, JLINK_ETX]) def parse_frames(data: bytes) -> list: """ Parse all valid JLink frames from a byte stream. Returns list of (cmd, payload) tuples for each valid frame. """ WAIT_STX, WAIT_LEN, WAIT_DATA, WAIT_CRC_HI, WAIT_CRC_LO, WAIT_ETX = range(6) state = WAIT_STX results = [] length = 0 buf = [] crc_hi = 0 for b in data: if state == WAIT_STX: if b == JLINK_STX: state = WAIT_LEN elif state == WAIT_LEN: if b == 0 or b > 65: state = WAIT_STX else: length = b buf = [] state = WAIT_DATA elif state == WAIT_DATA: buf.append(b) if len(buf) == length: state = WAIT_CRC_HI elif state == WAIT_CRC_HI: crc_hi = b state = WAIT_CRC_LO elif state == WAIT_CRC_LO: rx_crc = (crc_hi << 8) | b calc_crc = crc16_xmodem(bytes(buf)) if rx_crc == calc_crc: state = WAIT_ETX else: state = WAIT_STX elif state == WAIT_ETX: if b == JLINK_ETX: cmd = buf[0] payload = bytes(buf[1:]) results.append((cmd, payload)) state = WAIT_STX return results # --------------------------------------------------------------------------- # CRC16 Tests # --------------------------------------------------------------------------- class TestCRC16XModem: def test_empty(self): assert crc16_xmodem(b"") == 0x0000 def test_known_vector_1(self): # CRC16/XMODEM of b"123456789" = 0x31C3 assert crc16_xmodem(b"123456789") == 0x31C3 def test_single_zero(self): assert crc16_xmodem(b"\x00") == 0x0000 def test_single_ff(self): assert crc16_xmodem(b"\xFF") == 0x1EF0 def test_heartbeat_cmd_byte(self): # CRC of just CMD=0x01 crc = crc16_xmodem(bytes([JLINK_CMD_HEARTBEAT])) assert isinstance(crc, int) assert 0 <= crc <= 0xFFFF def test_drive_payload(self): payload = struct.pack(" bytes: return struct.pack(STATUS_FMT, pitch, roll, yaw, motor_cmd, vbat_mv, rssi_dbm, link_quality, balance_state, rc_armed, mode, estop, soc_pct, fw_major, fw_minor, fw_patch) class TestStatusTelemetry: def test_status_payload_size(self): assert STATUS_SIZE == 20 def test_status_frame_total_length(self): payload = make_status() frame = build_frame(JLINK_TLM_STATUS, payload) # [STX][LEN=21][0x80][20 bytes][CRC_hi][CRC_lo][ETX] = 26 bytes assert len(frame) == 26 def test_status_len_field(self): payload = make_status() frame = build_frame(JLINK_TLM_STATUS, payload) assert frame[1] == 21 # 1 (CMD) + 20 (STATUS payload) def test_status_cmd_byte(self): payload = make_status() frame = build_frame(JLINK_TLM_STATUS, payload) assert frame[2] == JLINK_TLM_STATUS def test_status_parseable(self): payload = make_status(pitch=123, roll=-45, yaw=678, motor_cmd=500, vbat_mv=11800, rssi_dbm=-90, link_quality=80, balance_state=1, rc_armed=1, mode=2, estop=0, soc_pct=60, fw_major=1, fw_minor=0, fw_patch=0) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) assert len(results) == 1 cmd, data = results[0] assert cmd == JLINK_TLM_STATUS vals = struct.unpack(STATUS_FMT, data) pitch, roll, yaw, motor_cmd, vbat_mv, rssi_dbm, lq, bs, ra, mode, es, soc, fwma, fwmi, fwpa = vals assert pitch == 123 assert roll == -45 assert yaw == 678 assert motor_cmd == 500 assert vbat_mv == 11800 assert rssi_dbm == -90 assert lq == 80 assert bs == 1 assert ra == 1 assert mode == 2 assert es == 0 assert soc == 60 assert fwma == 1 assert fwmi == 0 assert fwpa == 0 def test_pitch_encoding(self): """pitch_deg * 10 fits in int16 for ±3276 degrees.""" for deg in [-20.0, 0.0, 5.5, 15.3, -10.1]: encoded = int(deg * 10) payload = make_status(pitch=encoded) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) assert len(results) == 1 vals = struct.unpack(STATUS_FMT, results[0][1]) assert vals[0] == encoded def test_vbat_range_3s(self): """3S battery 9900–12600 mV fits in uint16.""" for mv in [9900, 11100, 12600]: payload = make_status(vbat_mv=mv) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) vals = struct.unpack(STATUS_FMT, results[0][1]) assert vals[4] == mv def test_vbat_range_4s(self): """4S battery 13200–16800 mV fits in uint16.""" for mv in [13200, 14800, 16800]: payload = make_status(vbat_mv=mv) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) vals = struct.unpack(STATUS_FMT, results[0][1]) assert vals[4] == mv def test_fw_version_fields(self): payload = make_status(fw_major=2, fw_minor=3, fw_patch=7) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) vals = struct.unpack(STATUS_FMT, results[0][1]) assert vals[-3] == 2 assert vals[-2] == 3 assert vals[-1] == 7 def test_soc_pct_unknown(self): """soc_pct=255 encodes as 0xFF (unknown).""" payload = make_status(soc_pct=255) frame = build_frame(JLINK_TLM_STATUS, payload) results = parse_frames(frame) vals = struct.unpack(STATUS_FMT, results[0][1]) assert vals[11] == 255