diff --git a/include/jlink.h b/include/jlink.h index d2699aa..d20a3e6 100644 --- a/include/jlink.h +++ b/include/jlink.h @@ -26,6 +26,7 @@ * 0x03 ARM — no payload; request arm (same interlock as CDC 'A') * 0x04 DISARM — no payload; disarm immediately * 0x05 PID_SET — float kp, float ki, float kd (12 bytes, IEEE-754 LE) + * 0x06 DFU_ENTER — no payload; request OTA DFU reboot (denied while armed) * 0x07 ESTOP — no payload; engage emergency stop * * STM32 → Jetson telemetry: @@ -50,6 +51,7 @@ #define JLINK_CMD_ARM 0x03u #define JLINK_CMD_DISARM 0x04u #define JLINK_CMD_PID_SET 0x05u +#define JLINK_CMD_DFU_ENTER 0x06u #define JLINK_CMD_ESTOP 0x07u /* ---- Telemetry IDs (STM32 → Jetson) ---- */ @@ -93,6 +95,9 @@ typedef struct { volatile float pid_kp; volatile float pid_ki; volatile float pid_kd; + + /* DFU reboot request — set by parser, cleared by main loop */ + volatile uint8_t dfu_req; } JLinkState; extern volatile JLinkState jlink_state; diff --git a/include/ota.h b/include/ota.h new file mode 100644 index 0000000..2b3d9a5 --- /dev/null +++ b/include/ota.h @@ -0,0 +1,63 @@ +#ifndef OTA_H +#define OTA_H + +#include +#include + +/* + * OTA firmware update — Issue #124 + * + * DFU entry triggered by JLINK_CMD_DFU_ENTER (0x06) or USB CDC 'R' command. + * Uses RTC backup register OTA_DFU_BKP_IDX to pass magic across the soft reset. + * + * RTC BKP register map: + * BKP0R–BKP5R : BNO055 calibration offsets (PR #150) + * BKP6R : BNO055 magic (0xB055CA10, PR #150) + * BKP7R–BKP14R : Reserved + * BKP15R : OTA DFU magic (this module) + * + * Using BKP15R avoids collision with BNO055 (BKP0–6) and the old BKP0R + * that the original request_bootloader() used before this module. + * + * Dual-bank note: STM32F722 has single-bank flash (512 KB). Hardware A/B + * rollback is not supported without a custom bootloader. DFU via the ST + * system bootloader at 0x1FF00000 is the supported update path. Rollback + * is handled by the host-side flash_firmware.py script, which keeps a + * backup of the previous binary. + */ + +/* RTC backup register index used for DFU magic — avoids BNO055 BKP0–6 */ +#define OTA_DFU_BKP_IDX 15u + +/* Magic value written before reset to trigger DFU entry on next boot */ +#define OTA_DFU_MAGIC 0xDEADBEEFu + +/* STM32F722 internal flash: 512 KB starting at 0x08000000 */ +#define OTA_FLASH_BASE 0x08000000u +#define OTA_FLASH_SIZE 0x00080000u /* 512 KB */ + +/* + * ota_enter_dfu(is_armed) + * + * Request entry to USB DFU mode (ST system bootloader at 0x1FF00000). + * Returns false without side effects if is_armed is true. + * Otherwise: enables backup domain, writes OTA_DFU_MAGIC to BKP15R, + * disables IRQs, calls NVIC_SystemReset(). Never returns on success. + * + * Call from the main loop only (not from ISR context). + */ +bool ota_enter_dfu(bool is_armed); + +/* + * ota_fw_crc32() + * + * Compute a CRC-32/MPEG-2 checksum of the full flash region using the + * STM32 hardware CRC peripheral (poly 0x04C11DB7, init 0xFFFFFFFF, + * 32-bit words, no reflection). Covers OTA_FLASH_SIZE bytes from + * OTA_FLASH_BASE including erased padding. + * + * Takes ~0.5 ms at 216 MHz. Call only while disarmed. + */ +uint32_t ota_fw_crc32(void); + +#endif /* OTA_H */ diff --git a/lib/USB_CDC/src/usbd_cdc_if.c b/lib/USB_CDC/src/usbd_cdc_if.c index 00ca3b7..efaab5a 100644 --- a/lib/USB_CDC/src/usbd_cdc_if.c +++ b/lib/USB_CDC/src/usbd_cdc_if.c @@ -1,5 +1,6 @@ #include "usbd_cdc_if.h" #include "stm32f7xx_hal.h" +#include "ota.h" extern USBD_HandleTypeDef hUsbDevice; volatile uint8_t cdc_streaming = 1; /* auto-stream */ @@ -53,11 +54,10 @@ void * const usb_nc_buf_base = &usb_nc_buf; * 2. NVIC_SystemReset() — clean hardware reset * 3. Early startup checks magic, clears it, jumps to system bootloader * - * The magic check happens in checkForBootloader() called from main.c - * before any peripheral init. + * Magic is written to BKP15R (OTA_DFU_BKP_IDX) — not BKP0R — so that + * BKP0R–BKP6R are available for BNO055 calibration offsets (PR #150). + * The magic check in checkForBootloader() reads the same BKP15R register. */ -#define BOOTLOADER_MAGIC 0xDEADBEEF - static void request_bootloader(void) { /* Betaflight-proven: write magic, disable IRQs, reset. * checkForBootloader() runs on next boot before anything else. */ @@ -65,8 +65,8 @@ static void request_bootloader(void) { HAL_PWR_EnableBkUpAccess(); __HAL_RCC_RTC_ENABLE(); - /* Write magic to RTC backup register 0 */ - RTC->BKP0R = BOOTLOADER_MAGIC; + /* Write magic to BKP15R via OTA module constants (avoids BNO055 BKP0–6) */ + (&RTC->BKP0R)[OTA_DFU_BKP_IDX] = OTA_DFU_MAGIC; __disable_irq(); NVIC_SystemReset(); @@ -89,15 +89,14 @@ void checkForBootloader(void) { HAL_PWR_EnableBkUpAccess(); __HAL_RCC_RTC_ENABLE(); - uint32_t magic = RTC->BKP0R; + uint32_t magic = (&RTC->BKP0R)[OTA_DFU_BKP_IDX]; /* read BKP15R */ - if (magic != BOOTLOADER_MAGIC) { + if (magic != OTA_DFU_MAGIC) { return; /* Normal boot */ } - /* Write POST marker (Betaflight does this so SystemInit can - * do a second reset if needed — we just clear it) */ - RTC->BKP0R = 0; + /* Clear magic so next boot is normal */ + (&RTC->BKP0R)[OTA_DFU_BKP_IDX] = 0; /* Jump to STM32F7 system bootloader at 0x1FF00000. * Exactly as Betaflight does it — no cache/VTOR/MEMRMP games needed diff --git a/scripts/flash_firmware.py b/scripts/flash_firmware.py new file mode 100644 index 0000000..8b884c4 --- /dev/null +++ b/scripts/flash_firmware.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""SaltyLab Firmware OTA Flash Script — Issue #124 + +Flashes firmware via USB DFU using dfu-util. +Supports CRC32 integrity verification and host-side backup/rollback. + +Usage: + python flash_firmware.py firmware.bin [options] + python flash_firmware.py --rollback + python flash_firmware.py firmware.bin --trigger-dfu /dev/ttyUSB0 + +Options: + --vid HEX USB vendor ID (default: 0x0483 — STMicroelectronics) + --pid HEX USB product ID (default: 0xDF11 — DFU mode) + --alt N DFU alt setting (default: 0 — internal flash) + --rollback Flash the previous firmware backup + --trigger-dfu PORT Send DFU_ENTER over JLink UART before flashing + --dry-run Print dfu-util command but do not execute + +Requirements: + pip install pyserial (only if using --trigger-dfu) + dfu-util >= 0.9 installed and in PATH + +Dual-bank note: + STM32F722 has single-bank 512 KB flash; hardware A/B rollback is not + supported. Rollback is implemented here by saving a backup of the + previous binary (.firmware_backup.bin) before each flash. +""" + +import argparse +import binascii +import os +import shutil +import struct +import subprocess +import sys +import time + +# ---- STM32F722 flash constants ---- +FLASH_BASE = 0x08000000 +FLASH_SIZE = 0x80000 # 512 KB + +# ---- DFU device defaults (STM32 system bootloader) ---- +DFU_VID = 0x0483 # STMicroelectronics +DFU_PID = 0xDF11 # DFU mode + +BACKUP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '.firmware_backup.bin') + + +# ---- CRC utilities ---- + +def crc32_file(path: str) -> int: + """ + Compute CRC-32/ISO-HDLC (standard Python binascii.crc32) of a file. + Used for pre-flash integrity verification; consistent across runs. + """ + with open(path, 'rb') as fh: + data = fh.read() + return binascii.crc32(data) & 0xFFFFFFFF + + +def stm32_crc32(data: bytes) -> int: + """ + Compute CRC-32/MPEG-2 matching STM32F7 hardware CRC unit. + + STM32 algorithm: + Polynomial : 0x04C11DB7 + Initial : 0xFFFFFFFF + Width : 32 bits + Reflection : none (MSB-first) + Feed size : 32-bit words from flash (little-endian CPU read) + + When the STM32 reads a flash word it gets a little-endian uint32; + the hardware CRC unit processes bits[31:24] first, then [23:16], + [15:8], [7:0]. This Python implementation replicates that behaviour. + + data should be padded to a 4-byte boundary with 0xFF before calling. + """ + if len(data) % 4: + data += b'\xff' * (4 - len(data) % 4) + + crc = 0xFFFFFFFF + for i in range(0, len(data), 4): + # Little-endian: byte0 is LSB, byte3 is MSB of the 32-bit word + word = struct.unpack_from(' int: + """CRC-16/XMODEM (poly 0x1021, init 0x0000) — JLink frame CRC.""" + crc = 0x0000 + for b in data: + crc ^= b << 8 + for _ in range(8): + if crc & 0x8000: + crc = ((crc << 1) ^ 0x1021) & 0xFFFF + else: + crc = (crc << 1) & 0xFFFF + return crc + + +def _build_jlink_frame(cmd: int, payload: bytes = b'') -> bytes: + """Build a JLink binary frame: [STX][LEN][CMD][PAYLOAD][CRC_hi][CRC_lo][ETX].""" + STX, ETX = 0x02, 0x03 + body = bytes([cmd]) + payload + length = len(body) + crc = _crc16_xmodem(body) + return bytes([STX, length, cmd]) + payload + bytes([crc >> 8, crc & 0xFF, ETX]) + + +def trigger_dfu_via_jlink(port: str, baud: int = 921600) -> None: + """Send JLINK_CMD_DFU_ENTER (0x06) over USART1 to put device in DFU mode.""" + try: + import serial + except ImportError: + print("ERROR: pyserial not installed. Run: pip install pyserial", + file=sys.stderr) + sys.exit(1) + + frame = _build_jlink_frame(0x06) # JLINK_CMD_DFU_ENTER, no payload + with serial.Serial(port, baud, timeout=2) as ser: + ser.write(frame) + time.sleep(0.1) + print(f"DFU_ENTER sent to {port} ({len(frame)} bytes)") + + +# ---- Flash ---- + +def flash(bin_path: str, vid: int, pid: int, alt: int = 0, + dry_run: bool = False) -> int: + """ + Flash firmware using dfu-util. Returns the process exit code. + + Uses --dfuse-address with :leave to reset into application after flash. + """ + addr = f'0x{FLASH_BASE:08X}' + cmd = [ + 'dfu-util', + '--device', f'{vid:04x}:{pid:04x}', + '--alt', str(alt), + '--dfuse-address', f'{addr}:leave', + '--download', bin_path, + ] + print('Running:', ' '.join(cmd)) + if dry_run: + print('[dry-run] skipping dfu-util execution') + return 0 + return subprocess.call(cmd) + + +# ---- Main ---- + +def main() -> int: + parser = argparse.ArgumentParser( + description='SaltyLab firmware OTA flash via USB DFU (Issue #124)' + ) + parser.add_argument('firmware', nargs='?', + help='Firmware .bin file to flash') + parser.add_argument('--vid', type=lambda x: int(x, 0), default=DFU_VID, + help=f'USB vendor ID (default: 0x{DFU_VID:04X})') + parser.add_argument('--pid', type=lambda x: int(x, 0), default=DFU_PID, + help=f'USB product ID (default: 0x{DFU_PID:04X})') + parser.add_argument('--alt', type=int, default=0, + help='DFU alt setting (default: 0 — internal flash)') + parser.add_argument('--rollback', action='store_true', + help='Flash the previous firmware backup') + parser.add_argument('--trigger-dfu', metavar='PORT', + help='Trigger DFU via JLink UART before flashing ' + '(e.g. /dev/ttyUSB0 or COM3)') + parser.add_argument('--dry-run', action='store_true', + help='Print dfu-util command without executing it') + args = parser.parse_args() + + # Optionally trigger DFU mode over JLink serial + if args.trigger_dfu: + trigger_dfu_via_jlink(args.trigger_dfu) + print('Waiting 3 s for USB DFU enumeration…') + time.sleep(3) + + # Determine target binary + if args.rollback: + if not os.path.exists(BACKUP_PATH): + print(f'ERROR: No backup found at {BACKUP_PATH}', file=sys.stderr) + return 1 + target = BACKUP_PATH + print(f'Rolling back to {BACKUP_PATH}') + elif args.firmware: + target = args.firmware + else: + parser.print_help() + return 1 + + if not os.path.exists(target): + print(f'ERROR: File not found: {target}', file=sys.stderr) + return 1 + + # CRC32 integrity check + crc_std = crc32_file(target) + size = os.path.getsize(target) + print(f'Binary : {target} ({size} bytes)') + print(f'CRC-32 : 0x{crc_std:08X} (ISO-HDLC)') + + if size > FLASH_SIZE: + print(f'ERROR: Binary ({size} bytes) exceeds flash size ' + f'({FLASH_SIZE} bytes)', file=sys.stderr) + return 1 + + # STM32 hardware CRC (for cross-checking with firmware telemetry) + with open(target, 'rb') as fh: + bin_data = fh.read() + crc_hw = stm32_crc32(bin_data.ljust(FLASH_SIZE, b'\xff')) + print(f'CRC-32 : 0x{crc_hw:08X} (MPEG-2 / STM32 HW, padded to {FLASH_SIZE // 1024} KB)') + + # Save backup before flashing (skip when rolling back) + if not args.rollback: + shutil.copy2(target, BACKUP_PATH) + print(f'Backup : {BACKUP_PATH}') + + # Flash + rc = flash(target, args.vid, args.pid, args.alt, args.dry_run) + if rc == 0: + print('Flash complete — device should reset into application.') + else: + print(f'ERROR: dfu-util exited with code {rc}', file=sys.stderr) + return rc + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/jlink.c b/src/jlink.c index bdf83ef..3d062c6 100644 --- a/src/jlink.c +++ b/src/jlink.c @@ -159,6 +159,11 @@ static void dispatch(const uint8_t *payload, uint8_t cmd, uint8_t plen) } break; + case JLINK_CMD_DFU_ENTER: + /* Payload-less; main loop checks armed state before calling ota_enter_dfu() */ + jlink_state.dfu_req = 1u; + break; + case JLINK_CMD_ESTOP: jlink_state.estop_req = 1u; break; diff --git a/src/main.c b/src/main.c index d61e7fe..b1f448c 100644 --- a/src/main.c +++ b/src/main.c @@ -17,6 +17,7 @@ #include "mag.h" #include "jetson_cmd.h" #include "jlink.h" +#include "ota.h" #include "battery.h" #include #include @@ -234,6 +235,12 @@ int main(void) { bal.ki = jlink_state.pid_ki; bal.kd = jlink_state.pid_kd; } + if (jlink_state.dfu_req) { + jlink_state.dfu_req = 0u; + /* ota_enter_dfu() is a no-op (returns false) when armed; + * never returns when disarmed — MCU resets into DFU mode. */ + ota_enter_dfu(bal.state == BALANCE_ARMED); + } /* RC CH5 kill switch: disarm immediately if RC is alive and CH5 off. * Applies regardless of active mode (CH5 always has kill authority). */ diff --git a/src/ota.c b/src/ota.c new file mode 100644 index 0000000..28c6038 --- /dev/null +++ b/src/ota.c @@ -0,0 +1,57 @@ +#include "ota.h" +#include "stm32f7xx_hal.h" + +/* ---- ota_enter_dfu() ---- */ +bool ota_enter_dfu(bool is_armed) +{ + if (is_armed) return false; + + /* Enable backup domain access */ + __HAL_RCC_PWR_CLK_ENABLE(); + HAL_PWR_EnableBkUpAccess(); + __HAL_RCC_RTC_ENABLE(); + + /* + * Write DFU magic to BKP15R. + * checkForBootloader() runs on next boot and jumps to the ST system + * bootloader at 0x1FF00000 when it finds this magic in BKP15R. + * BKP15R avoids the BNO055 calibration range (BKP0R–BKP6R). + * + * RTC->BKP0R through BKP31R are laid out consecutively in memory, + * so (&RTC->BKP0R)[OTA_DFU_BKP_IDX] reaches BKP15R. + */ + (&RTC->BKP0R)[OTA_DFU_BKP_IDX] = OTA_DFU_MAGIC; + + __disable_irq(); + NVIC_SystemReset(); + + return true; /* never reached */ +} + +/* ---- ota_fw_crc32() ---- */ +uint32_t ota_fw_crc32(void) +{ + /* + * STM32F7 hardware CRC unit: + * Polynomial : 0x04C11DB7 (CRC-32/MPEG-2) + * Initial : 0xFFFFFFFF (CRC_INIT register default) + * Width : 32 bits + * Reflection : none + * + * The unit processes one 32-bit word at a time. Feeding the full + * 512 KB flash (128 K words) takes ~0.5 ms at 216 MHz. + */ + __HAL_RCC_CRC_CLK_ENABLE(); + + /* Reset CRC state; keep default 32-bit polynomial and no reversal */ + CRC->CR = CRC_CR_RESET; + + const uint32_t *p = (const uint32_t *)OTA_FLASH_BASE; + uint32_t words = OTA_FLASH_SIZE / 4u; + + while (words--) { + CRC->DR = *p++; + } + + return CRC->DR; +} diff --git a/test/test_ota.py b/test/test_ota.py new file mode 100644 index 0000000..57eda29 --- /dev/null +++ b/test/test_ota.py @@ -0,0 +1,260 @@ +""" +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) # 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