- Add ota.h / ota.c: ota_enter_dfu() (armed guard, writes BKP15R, resets), ota_fw_crc32() using STM32F7 hardware CRC peripheral (CRC-32/MPEG-2, 512 KB) - Add JLINK_CMD_DFU_ENTER (0x06) and dfu_req flag to jlink.h / jlink.c - Handle dfu_req in main loop: calls ota_enter_dfu(is_armed) — no-op if armed - Update usbd_cdc_if.c: move DFU magic from BKP0R to BKP15R (OTA_DFU_BKP_IDX) resolving BKP register conflict with BNO055 calibration (BKP0R–6R, PR #150) - Add scripts/flash_firmware.py: CRC-32/MPEG-2 + ISO-HDLC verification, dfu-util flash, host-side backup/rollback, --trigger-dfu JLink serial path - Add test/test_ota.py: 42 tests passing (CRC-32/MPEG-2, CRC-16/XMODEM, DFU_ENTER frame structure, BKP register safety, flash constants) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
9.2 KiB
Python
261 lines
9.2 KiB
Python
"""
|
||
test_ota.py — OTA firmware update utilities (Issue #124)
|
||
|
||
Tests:
|
||
- CRC-32/ISO-HDLC (crc32_file / binascii.crc32)
|
||
- CRC-32/MPEG-2 (stm32_crc32 — matches STM32F7 hardware CRC unit)
|
||
- 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) # EF BE AD DE in memory
|
||
be_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
|
||
|
||
def test_bkp_idx_valid_stm32f7(self):
|
||
"""STM32F7 has 32 backup registers (BKP0R–BKP31R)."""
|
||
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):
|
||
"""Default PID = 0xDF11 (STM32 DFU mode)."""
|
||
assert DFU_PID == 0xDF11
|
||
|
||
def test_bkp_idx_not_zero(self):
|
||
"""BKP15 ≠ 0 — the old request_bootloader() used BKP0R."""
|
||
assert 15 != 0
|