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>
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""
|
||
test_bno055_data.py — Issue #135: BNO055 driver unit tests.
|
||
|
||
Tests data scaling, byte parsing, calibration status extraction,
|
||
calibration offset packing/unpacking, and temperature handling.
|
||
|
||
<<<<<<< HEAD
|
||
No HAL or STM32/ESP32 hardware required — pure Python logic.
|
||
=======
|
||
No HAL or ESP32-S3 hardware required — pure Python logic.
|
||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||
"""
|
||
|
||
import struct
|
||
import pytest
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# BNO055 scaling constants (mirror bno055.c)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
EULER_SCALE = 16.0 # 1 degree = 16 LSB
|
||
GYRO_SCALE = 16.0 # 1 dps = 16 LSB
|
||
ACCEL_SCALE = 100.0 # 1 m/s² = 100 LSB
|
||
G_MS2 = 9.80665
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calibration status bit extraction
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BNO055_CAL_SYS_MASK = 0xC0
|
||
BNO055_CAL_GYR_MASK = 0x30
|
||
BNO055_CAL_ACC_MASK = 0x0C
|
||
BNO055_CAL_MAG_MASK = 0x03
|
||
|
||
|
||
def parse_calib_status(byte):
|
||
"""Return (sys, gyr, acc, mag) calibration levels 0-3."""
|
||
sys = (byte & BNO055_CAL_SYS_MASK) >> 6
|
||
gyr = (byte & BNO055_CAL_GYR_MASK) >> 4
|
||
acc = (byte & BNO055_CAL_ACC_MASK) >> 2
|
||
mag = (byte & BNO055_CAL_MAG_MASK) >> 0
|
||
return sys, gyr, acc, mag
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Offset packing (mirrors bno055_save_offsets / bno055_restore_offsets)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BNO055_BKP_MAGIC = 0xB055CA10
|
||
|
||
|
||
def pack_offsets(offsets22: bytes):
|
||
"""Pack 22 bytes into 6 x uint32 words + magic word (7 words total)."""
|
||
assert len(offsets22) == 22
|
||
words = []
|
||
for i in range(5):
|
||
w = (offsets22[i * 4 + 0]
|
||
| (offsets22[i * 4 + 1] << 8)
|
||
| (offsets22[i * 4 + 2] << 16)
|
||
| (offsets22[i * 4 + 3] << 24))
|
||
words.append(w & 0xFFFFFFFF)
|
||
# Last word: only bytes 20 and 21
|
||
w5 = offsets22[20] | (offsets22[21] << 8)
|
||
words.append(w5 & 0xFFFFFFFF)
|
||
words.append(BNO055_BKP_MAGIC)
|
||
return words # 7 uint32 words
|
||
|
||
|
||
def unpack_offsets(words):
|
||
"""Reverse of pack_offsets. Returns 22 bytes."""
|
||
assert len(words) == 7
|
||
assert words[6] == BNO055_BKP_MAGIC
|
||
result = []
|
||
for i in range(5):
|
||
w = words[i]
|
||
result.append(w & 0xFF)
|
||
result.append((w >> 8) & 0xFF)
|
||
result.append((w >> 16) & 0xFF)
|
||
result.append((w >> 24) & 0xFF)
|
||
# Last word: only bytes 20 and 21
|
||
result.append(words[5] & 0xFF)
|
||
result.append((words[5] >> 8) & 0xFF)
|
||
return bytes(result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# IMUData parsing helpers (mirror bno055_read() logic)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def parse_euler(buf6: bytes):
|
||
"""Parse 6-byte Euler burst (H, R, P each int16 LE) → (heading, roll, pitch) in degrees."""
|
||
h_raw = struct.unpack_from("<h", buf6, 0)[0]
|
||
r_raw = struct.unpack_from("<h", buf6, 2)[0]
|
||
p_raw = struct.unpack_from("<h", buf6, 4)[0]
|
||
return h_raw / EULER_SCALE, r_raw / EULER_SCALE, p_raw / EULER_SCALE
|
||
|
||
|
||
def parse_gyro(buf6: bytes):
|
||
"""Parse 6-byte gyro burst (Gx, Gy, Gz each int16 LE) → (gx, gy, gz) in dps."""
|
||
gx = struct.unpack_from("<h", buf6, 0)[0] / GYRO_SCALE
|
||
gy = struct.unpack_from("<h", buf6, 2)[0] / GYRO_SCALE
|
||
gz = struct.unpack_from("<h", buf6, 4)[0] / GYRO_SCALE
|
||
return gx, gy, gz
|
||
|
||
|
||
def parse_lia_x(buf2: bytes):
|
||
"""Parse 2-byte linear accel X (int16 LE) → m/s²."""
|
||
raw = struct.unpack_from("<h", buf2, 0)[0]
|
||
return raw / ACCEL_SCALE
|
||
|
||
|
||
def parse_grv_z(buf2: bytes):
|
||
"""Parse 2-byte gravity Z (int16 LE) → m/s²."""
|
||
raw = struct.unpack_from("<h", buf2, 0)[0]
|
||
return raw / ACCEL_SCALE
|
||
|
||
|
||
def ms2_to_g(ms2):
|
||
return ms2 / G_MS2
|
||
|
||
|
||
# Mirroring bno055_read() 12-byte burst parsing:
|
||
# buf[0:5] = gyro X, Y, Z
|
||
# buf[6:11] = euler H, R, P
|
||
def parse_burst1(buf12: bytes):
|
||
"""Parse 12-byte burst1 (gyro 6B + euler 6B) from REG_GYRO_X_LSB."""
|
||
gyro = buf12[0:6]
|
||
euler = buf12[6:12]
|
||
_, pitch_rate, _ = parse_gyro(gyro) # gy = pitch rate
|
||
heading, roll, pitch = parse_euler(euler)
|
||
return pitch, roll, heading, pitch_rate
|
||
|
||
|
||
def parse_burst2(buf12: bytes):
|
||
"""Parse 12-byte burst2 (LIA 6B + GRV 6B) from REG_LIA_X_LSB."""
|
||
lia_x_ms2 = struct.unpack_from("<h", buf12, 0)[0] / ACCEL_SCALE # LIA X
|
||
grv_z_ms2 = struct.unpack_from("<h", buf12, 10)[0] / ACCEL_SCALE # GRV Z (bytes 10-11)
|
||
return ms2_to_g(lia_x_ms2), ms2_to_g(grv_z_ms2)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Calibration Status
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCalibStatus:
|
||
|
||
def test_all_uncalibrated(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0x00)
|
||
assert sys == 0 and gyr == 0 and acc == 0 and mag == 0
|
||
|
||
def test_all_fully_calibrated(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0xFF)
|
||
assert sys == 3 and gyr == 3 and acc == 3 and mag == 3
|
||
|
||
def test_sys_only(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0xC0)
|
||
assert sys == 3 and gyr == 0 and acc == 0 and mag == 0
|
||
|
||
def test_gyr_only(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0x30)
|
||
assert sys == 0 and gyr == 3 and acc == 0 and mag == 0
|
||
|
||
def test_acc_only(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0x0C)
|
||
assert sys == 0 and gyr == 0 and acc == 3 and mag == 0
|
||
|
||
def test_mag_only(self):
|
||
sys, gyr, acc, mag = parse_calib_status(0x03)
|
||
assert sys == 0 and gyr == 0 and acc == 0 and mag == 3
|
||
|
||
def test_partially_calibrated(self):
|
||
# sys=2, gyr=3, acc=1, mag=0 → 0b10_11_01_00 = 0xB4
|
||
sys, gyr, acc, mag = parse_calib_status(0xB4)
|
||
assert sys == 2 and gyr == 3 and acc == 1 and mag == 0
|
||
|
||
def test_bno055_is_ready_logic(self):
|
||
"""bno055_is_ready: requires acc≥2 AND gyr≥2 (when no offsets restored)."""
|
||
for calib_byte in range(256):
|
||
_, gyr, acc, _ = parse_calib_status(calib_byte)
|
||
expected_ready = (acc >= 2) and (gyr >= 2)
|
||
actual_ready = (acc >= 2) and (gyr >= 2)
|
||
assert actual_ready == expected_ready
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Euler Angle Parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestEulerParsing:
|
||
|
||
def test_zero(self):
|
||
buf = struct.pack("<hhh", 0, 0, 0)
|
||
h, r, p = parse_euler(buf)
|
||
assert h == 0.0 and r == 0.0 and p == 0.0
|
||
|
||
def test_heading_north(self):
|
||
buf = struct.pack("<hhh", 0, 0, 0)
|
||
h, _, _ = parse_euler(buf)
|
||
assert h == 0.0
|
||
|
||
def test_heading_east_90deg(self):
|
||
raw = int(90 * EULER_SCALE)
|
||
buf = struct.pack("<hhh", raw, 0, 0)
|
||
h, _, _ = parse_euler(buf)
|
||
assert abs(h - 90.0) < 0.001
|
||
|
||
def test_heading_360deg(self):
|
||
raw = int(360 * EULER_SCALE)
|
||
buf = struct.pack("<hhh", raw, 0, 0)
|
||
h, _, _ = parse_euler(buf)
|
||
assert abs(h - 360.0) < 0.001
|
||
|
||
def test_pitch_nose_up_5deg(self):
|
||
raw = int(5.0 * EULER_SCALE)
|
||
buf = struct.pack("<hhh", 0, 0, raw)
|
||
_, _, p = parse_euler(buf)
|
||
assert abs(p - 5.0) < 0.001
|
||
|
||
def test_pitch_nose_down_negative(self):
|
||
raw = int(-12.5 * EULER_SCALE)
|
||
buf = struct.pack("<hhh", 0, 0, raw)
|
||
_, _, p = parse_euler(buf)
|
||
assert abs(p - (-12.5)) < 0.001
|
||
|
||
def test_roll_positive(self):
|
||
raw = int(30.0 * EULER_SCALE)
|
||
buf = struct.pack("<hhh", 0, raw, 0)
|
||
_, r, _ = parse_euler(buf)
|
||
assert abs(r - 30.0) < 0.001
|
||
|
||
def test_fractional_degree(self):
|
||
"""0.0625 degree resolution (1/16)."""
|
||
raw = 1 # 1 LSB = 1/16 degree
|
||
buf = struct.pack("<hhh", raw, 0, 0)
|
||
h, _, _ = parse_euler(buf)
|
||
assert abs(h - (1.0 / EULER_SCALE)) < 1e-6
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Gyro Rate Parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestGyroParsing:
|
||
|
||
def test_zero_rates(self):
|
||
buf = struct.pack("<hhh", 0, 0, 0)
|
||
gx, gy, gz = parse_gyro(buf)
|
||
assert gx == 0.0 and gy == 0.0 and gz == 0.0
|
||
|
||
def test_pitch_rate_positive(self):
|
||
raw = int(45.0 * GYRO_SCALE) # 45 dps nose-up
|
||
buf = struct.pack("<hhh", 0, raw, 0)
|
||
_, gy, _ = parse_gyro(buf)
|
||
assert abs(gy - 45.0) < 0.001
|
||
|
||
def test_pitch_rate_negative(self):
|
||
raw = int(-90.0 * GYRO_SCALE)
|
||
buf = struct.pack("<hhh", 0, raw, 0)
|
||
_, gy, _ = parse_gyro(buf)
|
||
assert abs(gy - (-90.0)) < 0.001
|
||
|
||
def test_max_range_900dps(self):
|
||
raw = int(900 * GYRO_SCALE)
|
||
buf = struct.pack("<hhh", raw, 0, 0)
|
||
gx, _, _ = parse_gyro(buf)
|
||
assert abs(gx - 900.0) < 0.001
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Linear Accel + Gravity Parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestAccelParsing:
|
||
|
||
def test_zero_linear_accel(self):
|
||
buf = struct.pack("<h", 0)
|
||
assert parse_lia_x(buf) == 0.0
|
||
|
||
def test_forward_accel(self):
|
||
raw = int(1.0 * ACCEL_SCALE) # 1 m/s² forward
|
||
buf = struct.pack("<h", raw)
|
||
assert abs(parse_lia_x(buf) - 1.0) < 0.001
|
||
|
||
def test_level_gravity_z(self):
|
||
"""Level robot: gravity Z ≈ 9.80665 m/s²."""
|
||
raw = int(G_MS2 * ACCEL_SCALE)
|
||
buf = struct.pack("<h", raw)
|
||
assert abs(parse_grv_z(buf) - G_MS2) < 0.02
|
||
|
||
def test_gravity_to_g_conversion(self):
|
||
raw = int(G_MS2 * ACCEL_SCALE)
|
||
buf = struct.pack("<h", raw)
|
||
g_val = ms2_to_g(parse_grv_z(buf))
|
||
assert abs(g_val - 1.0) < 0.002
|
||
|
||
def test_lia_negative(self):
|
||
raw = int(-2.5 * ACCEL_SCALE)
|
||
buf = struct.pack("<h", raw)
|
||
assert abs(parse_lia_x(buf) - (-2.5)) < 0.001
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: 12-Byte Burst Parsing (mirrors bno055_read internals)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBurstParsing:
|
||
|
||
def _make_burst1(self, gx=0, gy=0, gz=0, heading=0.0, roll=0.0, pitch=0.0):
|
||
gyro = struct.pack("<hhh",
|
||
int(gx * GYRO_SCALE),
|
||
int(gy * GYRO_SCALE),
|
||
int(gz * GYRO_SCALE))
|
||
euler = struct.pack("<hhh",
|
||
int(heading * EULER_SCALE),
|
||
int(roll * EULER_SCALE),
|
||
int(pitch * EULER_SCALE))
|
||
return gyro + euler
|
||
|
||
def _make_burst2(self, lia_x_ms2=0.0, grv_z_ms2=G_MS2):
|
||
lia = struct.pack("<hhh", int(lia_x_ms2 * ACCEL_SCALE), 0, 0)
|
||
grv = struct.pack("<hhh", 0, 0, int(grv_z_ms2 * ACCEL_SCALE))
|
||
return lia + grv
|
||
|
||
def test_pitch_from_burst1(self):
|
||
buf = self._make_burst1(pitch=7.5)
|
||
pitch, _, _, _ = parse_burst1(buf)
|
||
assert abs(pitch - 7.5) < 0.001
|
||
|
||
def test_yaw_from_burst1_heading(self):
|
||
buf = self._make_burst1(heading=180.0)
|
||
_, _, yaw, _ = parse_burst1(buf)
|
||
assert abs(yaw - 180.0) < 0.001
|
||
|
||
def test_pitch_rate_from_burst1_gy(self):
|
||
buf = self._make_burst1(gy=30.0)
|
||
_, _, _, pitch_rate = parse_burst1(buf)
|
||
assert abs(pitch_rate - 30.0) < 0.001
|
||
|
||
def test_accel_x_g_from_burst2(self):
|
||
buf = self._make_burst2(lia_x_ms2=G_MS2) # 1g forward
|
||
accel_x_g, _ = parse_burst2(buf)
|
||
assert abs(accel_x_g - 1.0) < 0.002
|
||
|
||
def test_gravity_z_level_from_burst2(self):
|
||
buf = self._make_burst2(grv_z_ms2=G_MS2) # level
|
||
_, accel_z_g = parse_burst2(buf)
|
||
assert abs(accel_z_g - 1.0) < 0.002
|
||
|
||
def test_tilted_gravity(self):
|
||
"""Tilted 30°: gravity Z = cos(30°) × 9.80665 ≈ 8.495 m/s²."""
|
||
import math
|
||
grv_z = G_MS2 * math.cos(math.radians(30))
|
||
buf = self._make_burst2(grv_z_ms2=grv_z)
|
||
_, accel_z_g = parse_burst2(buf)
|
||
expected = grv_z / G_MS2
|
||
assert abs(accel_z_g - expected) < 0.01
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Calibration Offset Packing / Backup Register Round-trip
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestOffsetPacking:
|
||
|
||
def _make_offsets(self, fill=0):
|
||
return bytes([fill] * 22)
|
||
|
||
def test_pack_length(self):
|
||
words = pack_offsets(self._make_offsets(0))
|
||
assert len(words) == 7
|
||
|
||
def test_magic_in_last_word(self):
|
||
words = pack_offsets(self._make_offsets(0))
|
||
assert words[6] == BNO055_BKP_MAGIC
|
||
|
||
def test_round_trip_zeros(self):
|
||
orig = self._make_offsets(0)
|
||
words = pack_offsets(orig)
|
||
recovered = unpack_offsets(words)
|
||
assert recovered == orig
|
||
|
||
def test_round_trip_all_ones(self):
|
||
orig = self._make_offsets(0xFF)
|
||
words = pack_offsets(orig)
|
||
recovered = unpack_offsets(words)
|
||
assert recovered == orig
|
||
|
||
def test_round_trip_sequential(self):
|
||
orig = bytes(range(22))
|
||
words = pack_offsets(orig)
|
||
recovered = unpack_offsets(words)
|
||
assert recovered == orig
|
||
|
||
def test_round_trip_real_offsets(self):
|
||
"""Simulate a realistic BNO055 calibration offset set."""
|
||
# accel offset X/Y/Z (int16 LE), mag offset X/Y/Z, gyro offset X/Y/Z, radii
|
||
data = struct.pack("<hhhhhhhhhhh",
|
||
-45, 12, 980, # accel offsets (mg units)
|
||
150, -200, 50, # mag offsets (LSB units)
|
||
3, -7, 2, # gyro offsets (dps×16)
|
||
1000, 800) # accel_radius, mag_radius
|
||
assert len(data) == 22
|
||
words = pack_offsets(data)
|
||
recovered = unpack_offsets(words)
|
||
assert recovered == data
|
||
|
||
def test_invalid_magic_rejected(self):
|
||
words = pack_offsets(bytes(range(22)))
|
||
words_bad = words[:6] + [0xDEADBEEF] # corrupt magic
|
||
with pytest.raises(AssertionError):
|
||
unpack_offsets(words_bad)
|
||
|
||
def test_only_22_bytes_preserved(self):
|
||
"""Words 5's high 2 bytes are unused — should be zero after unpack."""
|
||
orig = bytes(range(22))
|
||
words = pack_offsets(orig)
|
||
# High 2 bytes of word[5] should be zero (only 2 bytes used)
|
||
assert (words[5] >> 16) == 0
|
||
|
||
def test_byte_order_word0(self):
|
||
"""First 4 bytes of offsets pack into word[0] as little-endian."""
|
||
orig = bytes([0x01, 0x02, 0x03, 0x04] + [0] * 18)
|
||
words = pack_offsets(orig)
|
||
assert words[0] == 0x04030201
|
||
|
||
def test_byte_order_word5_low(self):
|
||
"""Bytes 20-21 pack into low 16 bits of word[5]."""
|
||
orig = bytes([0] * 20 + [0xAB, 0xCD])
|
||
words = pack_offsets(orig)
|
||
assert words[5] == 0xCDAB
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Temperature
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestTemperature:
|
||
|
||
def test_room_temp_range(self):
|
||
"""Room temperature should be in –40..+85°C range."""
|
||
for t in [-40, 0, 25, 85]:
|
||
assert -40 <= t <= 85
|
||
|
||
def test_temperature_is_signed_byte(self):
|
||
"""BNO055 TEMP register is signed int8 → –128..+127 but useful range –40..+85."""
|
||
import ctypes
|
||
for raw in [25, 0x19, 0xFF]:
|
||
val = ctypes.c_int8(raw).value
|
||
assert -128 <= val <= 127
|