Merge pull request 'feat(firmware): OTA firmware update — USB DFU + dual-bank + CRC32 (Issue #124)' (#156) from sl-firmware/issue-124-ota into main
This commit is contained in:
commit
0f42e701e9
@ -26,6 +26,7 @@
|
|||||||
* 0x03 ARM — no payload; request arm (same interlock as CDC 'A')
|
* 0x03 ARM — no payload; request arm (same interlock as CDC 'A')
|
||||||
* 0x04 DISARM — no payload; disarm immediately
|
* 0x04 DISARM — no payload; disarm immediately
|
||||||
* 0x05 PID_SET — float kp, float ki, float kd (12 bytes, IEEE-754 LE)
|
* 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
|
* 0x07 ESTOP — no payload; engage emergency stop
|
||||||
*
|
*
|
||||||
* STM32 → Jetson telemetry:
|
* STM32 → Jetson telemetry:
|
||||||
@ -50,6 +51,7 @@
|
|||||||
#define JLINK_CMD_ARM 0x03u
|
#define JLINK_CMD_ARM 0x03u
|
||||||
#define JLINK_CMD_DISARM 0x04u
|
#define JLINK_CMD_DISARM 0x04u
|
||||||
#define JLINK_CMD_PID_SET 0x05u
|
#define JLINK_CMD_PID_SET 0x05u
|
||||||
|
#define JLINK_CMD_DFU_ENTER 0x06u
|
||||||
#define JLINK_CMD_ESTOP 0x07u
|
#define JLINK_CMD_ESTOP 0x07u
|
||||||
|
|
||||||
/* ---- Telemetry IDs (STM32 → Jetson) ---- */
|
/* ---- Telemetry IDs (STM32 → Jetson) ---- */
|
||||||
@ -93,6 +95,9 @@ typedef struct {
|
|||||||
volatile float pid_kp;
|
volatile float pid_kp;
|
||||||
volatile float pid_ki;
|
volatile float pid_ki;
|
||||||
volatile float pid_kd;
|
volatile float pid_kd;
|
||||||
|
|
||||||
|
/* DFU reboot request — set by parser, cleared by main loop */
|
||||||
|
volatile uint8_t dfu_req;
|
||||||
} JLinkState;
|
} JLinkState;
|
||||||
|
|
||||||
extern volatile JLinkState jlink_state;
|
extern volatile JLinkState jlink_state;
|
||||||
|
|||||||
63
include/ota.h
Normal file
63
include/ota.h
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#ifndef OTA_H
|
||||||
|
#define OTA_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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 */
|
||||||
@ -1,5 +1,6 @@
|
|||||||
#include "usbd_cdc_if.h"
|
#include "usbd_cdc_if.h"
|
||||||
#include "stm32f7xx_hal.h"
|
#include "stm32f7xx_hal.h"
|
||||||
|
#include "ota.h"
|
||||||
|
|
||||||
extern USBD_HandleTypeDef hUsbDevice;
|
extern USBD_HandleTypeDef hUsbDevice;
|
||||||
volatile uint8_t cdc_streaming = 1; /* auto-stream */
|
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
|
* 2. NVIC_SystemReset() — clean hardware reset
|
||||||
* 3. Early startup checks magic, clears it, jumps to system bootloader
|
* 3. Early startup checks magic, clears it, jumps to system bootloader
|
||||||
*
|
*
|
||||||
* The magic check happens in checkForBootloader() called from main.c
|
* Magic is written to BKP15R (OTA_DFU_BKP_IDX) — not BKP0R — so that
|
||||||
* before any peripheral init.
|
* 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) {
|
static void request_bootloader(void) {
|
||||||
/* Betaflight-proven: write magic, disable IRQs, reset.
|
/* Betaflight-proven: write magic, disable IRQs, reset.
|
||||||
* checkForBootloader() runs on next boot before anything else. */
|
* checkForBootloader() runs on next boot before anything else. */
|
||||||
@ -65,8 +65,8 @@ static void request_bootloader(void) {
|
|||||||
HAL_PWR_EnableBkUpAccess();
|
HAL_PWR_EnableBkUpAccess();
|
||||||
__HAL_RCC_RTC_ENABLE();
|
__HAL_RCC_RTC_ENABLE();
|
||||||
|
|
||||||
/* Write magic to RTC backup register 0 */
|
/* Write magic to BKP15R via OTA module constants (avoids BNO055 BKP0–6) */
|
||||||
RTC->BKP0R = BOOTLOADER_MAGIC;
|
(&RTC->BKP0R)[OTA_DFU_BKP_IDX] = OTA_DFU_MAGIC;
|
||||||
|
|
||||||
__disable_irq();
|
__disable_irq();
|
||||||
NVIC_SystemReset();
|
NVIC_SystemReset();
|
||||||
@ -89,15 +89,14 @@ void checkForBootloader(void) {
|
|||||||
HAL_PWR_EnableBkUpAccess();
|
HAL_PWR_EnableBkUpAccess();
|
||||||
__HAL_RCC_RTC_ENABLE();
|
__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 */
|
return; /* Normal boot */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write POST marker (Betaflight does this so SystemInit can
|
/* Clear magic so next boot is normal */
|
||||||
* do a second reset if needed — we just clear it) */
|
(&RTC->BKP0R)[OTA_DFU_BKP_IDX] = 0;
|
||||||
RTC->BKP0R = 0;
|
|
||||||
|
|
||||||
/* Jump to STM32F7 system bootloader at 0x1FF00000.
|
/* Jump to STM32F7 system bootloader at 0x1FF00000.
|
||||||
* Exactly as Betaflight does it — no cache/VTOR/MEMRMP games needed
|
* Exactly as Betaflight does it — no cache/VTOR/MEMRMP games needed
|
||||||
|
|||||||
238
scripts/flash_firmware.py
Normal file
238
scripts/flash_firmware.py
Normal file
@ -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('<I', data, i)[0]
|
||||||
|
crc ^= word
|
||||||
|
for _ in range(32):
|
||||||
|
if crc & 0x80000000:
|
||||||
|
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF
|
||||||
|
else:
|
||||||
|
crc = (crc << 1) & 0xFFFFFFFF
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
# ---- JLink protocol helpers ----
|
||||||
|
|
||||||
|
def _crc16_xmodem(data: bytes) -> 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())
|
||||||
@ -159,6 +159,11 @@ static void dispatch(const uint8_t *payload, uint8_t cmd, uint8_t plen)
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
case JLINK_CMD_ESTOP:
|
||||||
jlink_state.estop_req = 1u;
|
jlink_state.estop_req = 1u;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
#include "mag.h"
|
#include "mag.h"
|
||||||
#include "jetson_cmd.h"
|
#include "jetson_cmd.h"
|
||||||
#include "jlink.h"
|
#include "jlink.h"
|
||||||
|
#include "ota.h"
|
||||||
#include "battery.h"
|
#include "battery.h"
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@ -234,6 +235,12 @@ int main(void) {
|
|||||||
bal.ki = jlink_state.pid_ki;
|
bal.ki = jlink_state.pid_ki;
|
||||||
bal.kd = jlink_state.pid_kd;
|
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.
|
/* RC CH5 kill switch: disarm immediately if RC is alive and CH5 off.
|
||||||
* Applies regardless of active mode (CH5 always has kill authority). */
|
* Applies regardless of active mode (CH5 always has kill authority). */
|
||||||
|
|||||||
57
src/ota.c
Normal file
57
src/ota.c
Normal file
@ -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;
|
||||||
|
}
|
||||||
260
test/test_ota.py
Normal file
260
test/test_ota.py
Normal file
@ -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) # 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
|
||||||
Loading…
x
Reference in New Issue
Block a user