saltylab-firmware/test/test_ota.py
sl-firmware fa75c442a7 feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only
Archive STM32 firmware to legacy/stm32/:
- src/, include/, lib/USB_CDC/, platformio.ini, test stubs, flash_firmware.py
- test/test_battery_adc.c, test_hw_button.c, test_pid_schedule.c, test_vesc_can.c, test_can_watchdog.c
- USB_CDC_BUG.md

Rename: stm32_protocol → esp32_protocol, mamba_protocol → balance_protocol,
  stm32_cmd_node → esp32_cmd_node, stm32_cmd_params → esp32_cmd_params,
  stm32_cmd.launch.py → esp32_cmd.launch.py,
  test_stm32_protocol → test_esp32_protocol, test_stm32_cmd_node → test_esp32_cmd_node

Content cleanup across all files:
- Mamba F722S → ESP32-S3 BALANCE
- BlackPill → ESP32-S3 IO
- STM32F722/F7xx → ESP32-S3
- stm32Mode/Version/Port → esp32Mode/Version/Port
- STM32 State/Mode labels → ESP32 State/Mode
- Jetson Nano → Jetson Orin Nano Super
- /dev/stm32 → /dev/esp32
- stm32_bridge → esp32_bridge
- STM32 HAL → ESP-IDF

docs/SALTYLAB.md:
- Update "Drone FC Details" to describe ESP32-S3 BALANCE board (Waveshare ESP32-S3 Touch LCD 1.28)
- Replace verbose "Self-Balancing Control" STM32 section with brief note pointing to SAUL-TEE-SYSTEM-REFERENCE.md

TEAM.md: Update Embedded Firmware Engineer role to ESP32-S3 / ESP-IDF

No new functionality — cleanup only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:00:38 -04:00

274 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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) # 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 BKP0BKP6 (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 (BKP0RBKP31R)."""
=======
def test_bkp_idx_valid_esp32s3(self):
"""ESP32-S3 has 32 backup registers (BKP0RBKP31R)."""
>>>>>>> 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