""" test_ota.py — OTA firmware update utilities (Issue #124) Tests: - CRC-32/ISO-HDLC (crc32_file / binascii.crc32) <<<<<<< HEAD - CRC-32/MPEG-2 (stm32_crc32 — matches ESP32 hardware CRC unit) ======= - CRC-32/MPEG-2 (stm32_crc32 — matches ESP32-S3 hardware CRC unit) >>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) - CRC-16/XMODEM (_crc16_xmodem — JLink frame integrity) - DFU_ENTER frame (JLINK_CMD_DFU_ENTER = 0x06, no payload) - Safety constants (BKP index, flash region, magic value) """ import binascii import os import struct import sys import tempfile import pytest # Add scripts directory to path so we can import flash_firmware.py sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) from flash_firmware import ( crc32_file, stm32_crc32, _crc16_xmodem, _build_jlink_frame, FLASH_BASE, FLASH_SIZE, DFU_VID, DFU_PID, ) # ── CRC-32/ISO-HDLC (crc32_file) ────────────────────────────────────────── class TestCrc32File: def test_known_empty(self, tmp_path): """binascii.crc32 of empty file = 0x00000000.""" p = tmp_path / "empty.bin" p.write_bytes(b'') assert crc32_file(str(p)) == 0x00000000 def test_known_sequence(self, tmp_path): """CRC-32/ISO-HDLC of b'123456789' = 0xCBF43926 (well-known vector).""" p = tmp_path / "seq.bin" p.write_bytes(b'123456789') assert crc32_file(str(p)) == 0xCBF43926 def test_deterministic(self, tmp_path): """Same file produces same result on repeated calls.""" p = tmp_path / "data.bin" p.write_bytes(b'\xDE\xAD\xBE\xEF' * 256) assert crc32_file(str(p)) == crc32_file(str(p)) def test_single_bit_flip(self, tmp_path): """Flipping one bit changes the CRC.""" p0 = tmp_path / "d0.bin" p1 = tmp_path / "d1.bin" p0.write_bytes(b'\x00' * 64) p1.write_bytes(b'\x01' + b'\x00' * 63) assert crc32_file(str(p0)) != crc32_file(str(p1)) def test_result_is_uint32(self, tmp_path): """Result fits in 32 bits.""" p = tmp_path / "rnd.bin" p.write_bytes(bytes(range(256))) result = crc32_file(str(p)) assert 0 <= result <= 0xFFFFFFFF # ── CRC-32/MPEG-2 (stm32_crc32) ─────────────────────────────────────────── class TestStm32Crc32: def test_result_is_uint32(self): assert 0 <= stm32_crc32(b'\x00\x00\x00\x00') <= 0xFFFFFFFF def test_deterministic(self): data = b'\xDE\xAD\xBE\xEF' * 256 assert stm32_crc32(data) == stm32_crc32(data) def test_avalanche(self): d0 = b'\x00' * 256 d1 = b'\x01' + b'\x00' * 255 assert stm32_crc32(d0) != stm32_crc32(d1) def test_differs_from_iso_hdlc(self): """MPEG-2 and ISO-HDLC produce different results for non-trivial input.""" data = b'\x01\x02\x03\x04' * 64 iso = binascii.crc32(data) & 0xFFFFFFFF mpeg = stm32_crc32(data) assert iso != mpeg, "CRC algorithms should differ" def test_pads_to_4bytes(self): """Odd-length input padded to 4-byte boundary with 0xFF.""" assert stm32_crc32(b'\xAA\xBB\xCC') == stm32_crc32(b'\xAA\xBB\xCC\xFF') def test_full_flash_consistent(self): """All-0xFF 512 KB (erased flash) produces a consistent result.""" data = b'\xFF' * FLASH_SIZE r1 = stm32_crc32(data) r2 = stm32_crc32(data) assert r1 == r2 assert 0 <= r1 <= 0xFFFFFFFF def test_512kb_multiple_of_4(self): """Flash size is a multiple of 4 (no padding needed for full image).""" assert FLASH_SIZE % 4 == 0 def test_different_data_different_crc(self): """Two distinct 4-byte words produce different CRC.""" a = stm32_crc32(b'\x00\x00\x00\x00') b = stm32_crc32(b'\xFF\xFF\xFF\xFF') assert a != b def test_word_endian_sensitivity(self): """Byte ordering within a 32-bit word affects the result.""" le_word = struct.pack('I', 0xDEADBEEF) # DE AD BE EF in memory assert stm32_crc32(le_word) != stm32_crc32(be_word) # ── CRC-16/XMODEM (_crc16_xmodem) ───────────────────────────────────────── class TestCrc16Xmodem: def test_empty(self): """CRC16 of empty input = 0x0000.""" assert _crc16_xmodem(b'') == 0x0000 def test_known_vector(self): """CRC-16/XMODEM of b'123456789' = 0x31C3 (well-known vector).""" assert _crc16_xmodem(b'123456789') == 0x31C3 def test_single_heartbeat_byte(self): """CRC of HEARTBEAT command byte (0x01) is deterministic and non-zero.""" r = _crc16_xmodem(bytes([0x01])) assert isinstance(r, int) assert 0 <= r <= 0xFFFF assert r != 0 def test_dfu_cmd_byte(self): """CRC of DFU_ENTER command byte (0x06) is within 16-bit range.""" r = _crc16_xmodem(bytes([0x06])) assert 0 <= r <= 0xFFFF def test_deterministic(self): data = b'\xCA\xFE\xBA\xBE' assert _crc16_xmodem(data) == _crc16_xmodem(data) def test_avalanche(self): assert _crc16_xmodem(b'\x00') != _crc16_xmodem(b'\x01') def test_two_byte_differs_from_one_byte(self): """CRC of two-byte input differs from single-byte input.""" assert _crc16_xmodem(bytes([0x06, 0x00])) != _crc16_xmodem(bytes([0x06])) # ── DFU_ENTER frame structure ────────────────────────────────────────────── JLINK_CMD_DFU_ENTER = 0x06 JLINK_CMD_HEARTBEAT = 0x01 JLINK_CMD_ESTOP = 0x07 class TestDfuEnterFrame: def test_cmd_id(self): """JLINK_CMD_DFU_ENTER is 0x06 (between PID_SET=0x05 and ESTOP=0x07).""" assert JLINK_CMD_DFU_ENTER == 0x06 def test_cmd_id_between_pid_and_estop(self): assert JLINK_CMD_DFU_ENTER > 0x05 # > PID_SET assert JLINK_CMD_DFU_ENTER < JLINK_CMD_ESTOP def test_frame_length(self): """DFU_ENTER frame = 6 bytes: STX LEN CMD CRC_hi CRC_lo ETX.""" frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) assert len(frame) == 6 def test_frame_stx(self): frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) assert frame[0] == 0x02 def test_frame_etx(self): frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) assert frame[-1] == 0x03 def test_frame_len_byte(self): """LEN byte = 1 (CMD only, no payload).""" frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) assert frame[1] == 1 def test_frame_cmd_byte(self): frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) assert frame[2] == JLINK_CMD_DFU_ENTER def test_frame_crc_valid(self): """Embedded CRC validates against CMD byte.""" frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) cmd_byte = frame[2] crc_hi = frame[3] crc_lo = frame[4] expected = _crc16_xmodem(bytes([cmd_byte])) assert (crc_hi << 8 | crc_lo) == expected def test_frame_differs_from_heartbeat(self): """DFU_ENTER frame is distinct from HEARTBEAT frame.""" assert (_build_jlink_frame(JLINK_CMD_DFU_ENTER) != _build_jlink_frame(JLINK_CMD_HEARTBEAT)) def test_frame_differs_from_estop(self): assert (_build_jlink_frame(JLINK_CMD_DFU_ENTER) != _build_jlink_frame(JLINK_CMD_ESTOP)) def test_no_payload(self): """DFU_ENTER frame has no payload bytes between CMD and CRC.""" frame = _build_jlink_frame(JLINK_CMD_DFU_ENTER) # Layout: STX LEN CMD [no payload here] CRC_hi CRC_lo ETX assert len(frame) == 6 # STX(1)+LEN(1)+CMD(1)+CRC(2)+ETX(1) # ── Safety / constants ───────────────────────────────────────────────────── class TestOtaConstants: def test_dfu_magic(self): """DFU magic = 0xDEADBEEF (Betaflight-proven pattern).""" OTA_DFU_MAGIC = 0xDEADBEEF assert OTA_DFU_MAGIC == 0xDEADBEEF def test_dfu_magic_fits_uint32(self): assert 0 <= 0xDEADBEEF <= 0xFFFFFFFF def test_bkp_idx_avoids_bno055(self): """BKP15 does not overlap BNO055 range BKP0–BKP6 (PR #150).""" OTA_DFU_BKP_IDX = 15 BNO055_BKP_RANGE = range(0, 7) assert OTA_DFU_BKP_IDX not in BNO055_BKP_RANGE <<<<<<< HEAD def test_bkp_idx_valid_esp32(self): """ESP32 has 32 backup registers (BKP0R–BKP31R).""" ======= def test_bkp_idx_valid_esp32s3(self): """ESP32-S3 has 32 backup registers (BKP0R–BKP31R).""" >>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) OTA_DFU_BKP_IDX = 15 assert 0 <= OTA_DFU_BKP_IDX <= 31 def test_flash_base(self): assert FLASH_BASE == 0x08000000 def test_flash_size_512k(self): assert FLASH_SIZE == 512 * 1024 def test_flash_size_word_aligned(self): assert FLASH_SIZE % 4 == 0 def test_dfu_vid_stmicro(self): """Default VID = 0x0483 (STMicroelectronics).""" assert DFU_VID == 0x0483 def test_dfu_pid_dfu_mode(self): <<<<<<< HEAD """Default PID = 0xDF11 (ESP32 DFU mode).""" ======= """Default PID = 0xDF11 (ESP32-S3 DFU mode).""" >>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) assert DFU_PID == 0xDF11 def test_bkp_idx_not_zero(self): """BKP15 ≠ 0 — the old request_bootloader() used BKP0R.""" assert 15 != 0