saltylab-firmware/test/test_crsf_frames.py
sl-firmware 4a46fad002 feat(rc): CRSF/ELRS RC integration — telemetry uplink + channel fix (Issue #103)
## Summary
- config.h: CH1[0]=steer, CH2[1]=throttle (was CH4/CH3); CRSF_FAILSAFE_MS→500ms
- include/battery.h + src/battery.c: ADC3 Vbat reading on PC1 (11:1 divider)
  battery_read_mv(), battery_estimate_pct() for 3S/4S auto-detection
- include/crsf.h + src/crsf.c: CRSF telemetry TX uplink
  crsf_send_battery() — type 0x08, voltage/current/SoC to ELRS TX module
  crsf_send_flight_mode() — type 0x21, "ARMED\0"/"DISARM\0" for handset OSD
- src/main.c: battery_init() after crsf_init(); 1Hz telemetry tick calls
  crsf_send_battery(vbat_mv, 0, soc_pct) + crsf_send_flight_mode(armed)
- test/test_crsf_frames.py: 28 pytest tests — CRC8-DVB-S2, battery frame
  layout/encoding, flight-mode frame, battery_estimate_pct SoC math

Existing (already complete from crsf-elrs branch):
  CRSF frame decoder UART4 420000 baud DMA circular + IDLE interrupt
  Mode manager: RC↔autonomous blend, CH6 3-pos switch, 500ms smooth transition
  Failsafe in main.c: disarm if crsf_state.last_rx_ms stale > CRSF_FAILSAFE_MS
  CH5 arm switch with ARMING_HOLD_MS interlock + edge detection
  RC override: mode_manager blends steer/speed per mode (CH6)

Closes #103

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 08:35:48 -05:00

238 lines
8.1 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_crsf_frames.py — Unit tests for CRSF telemetry frame building (Issue #103).
Mirrors the C logic in src/crsf.c for crsf_send_battery() and
crsf_send_flight_mode() so frame layout and CRC can be verified
without flashing hardware.
Run with: pytest test/test_crsf_frames.py -v
"""
import struct
import pytest
# ── CRC8 DVB-S2 (polynomial 0xD5) ─────────────────────────────────────────
def crc8_dvb_s2(crc: int, byte: int) -> int:
crc ^= byte
for _ in range(8):
crc = ((crc << 1) ^ 0xD5) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
return crc
def crsf_frame_crc(frame: bytes) -> int:
"""CRC covers frame[2] (type) through frame[-2] (last payload byte)."""
crc = 0
for b in frame[2:-1]:
crc = crc8_dvb_s2(crc, b)
return crc
# ── Frame builders matching C implementation ───────────────────────────────
CRSF_SYNC = 0xC8
def build_battery_frame(voltage_mv: int, current_ma: int,
remaining_pct: int) -> bytes:
"""
Type 0x08 — battery sensor.
voltage : 100 mV units, uint16 big-endian
current : 100 mA units, uint16 big-endian
capacity : 0 mAh (not tracked), uint24 big-endian
remaining: 0100 %, uint8
Total payload = 8 bytes.
"""
v100 = voltage_mv // 100
c100 = current_ma // 100
payload = struct.pack('>HH3sB',
v100, c100,
b'\x00\x00\x00',
remaining_pct)
frame_type = 0x08
# SYNC + LEN(TYPE+payload+CRC) + TYPE + payload + CRC
frame_body = bytes([CRSF_SYNC, len(payload) + 2, frame_type]) + payload
frame = frame_body + b'\x00' # placeholder CRC slot
crc = crsf_frame_crc(frame)
return frame_body + bytes([crc])
def build_flight_mode_frame(armed: bool) -> bytes:
"""Type 0x21 — flight mode text, null-terminated."""
text = b'ARMED\x00' if armed else b'DISARM\x00'
frame_type = 0x21
frame_body = bytes([CRSF_SYNC, len(text) + 2, frame_type]) + text
frame = frame_body + b'\x00'
crc = crsf_frame_crc(frame)
return frame_body + bytes([crc])
# ── Helpers ────────────────────────────────────────────────────────────────
def validate_frame(frame: bytes):
"""Assert basic CRSF frame invariants."""
assert frame[0] == CRSF_SYNC, "bad sync byte"
length_field = frame[1]
total = length_field + 2 # SYNC + LEN + rest
assert len(frame) == total, f"frame length mismatch: {len(frame)} != {total}"
assert len(frame) <= 64, "frame too long (CRSF max 64 bytes)"
expected_crc = crsf_frame_crc(frame)
assert frame[-1] == expected_crc, \
f"CRC mismatch: got {frame[-1]:#04x}, expected {expected_crc:#04x}"
# ── Battery frame tests ────────────────────────────────────────────────────
class TestBatteryFrame:
def test_sync_byte(self):
f = build_battery_frame(12600, 0, 100)
assert f[0] == 0xC8
def test_frame_type(self):
f = build_battery_frame(12600, 0, 100)
assert f[2] == 0x08
def test_frame_invariants(self):
validate_frame(build_battery_frame(12600, 5000, 80))
def test_voltage_encoding_3s_full(self):
"""12.6 V → 126 in 100 mV units, big-endian."""
f = build_battery_frame(12600, 0, 100)
v100 = (f[3] << 8) | f[4]
assert v100 == 126
def test_voltage_encoding_4s_full(self):
"""16.8 V → 168."""
f = build_battery_frame(16800, 0, 100)
v100 = (f[3] << 8) | f[4]
assert v100 == 168
def test_current_encoding(self):
"""5000 mA → 50 in 100 mA units."""
f = build_battery_frame(12000, 5000, 75)
c100 = (f[5] << 8) | f[6]
assert c100 == 50
def test_remaining_pct(self):
f = build_battery_frame(11000, 0, 42)
assert f[10] == 42
def test_capacity_zero(self):
"""Capacity bytes (cap_hi, cap_mid, cap_lo) are zero — no coulomb counter."""
f = build_battery_frame(12600, 0, 100)
assert f[7] == 0 and f[8] == 0 and f[9] == 0
def test_crc_correct(self):
f = build_battery_frame(11500, 2000, 65)
validate_frame(f)
def test_zero_voltage(self):
"""Disconnected battery → 0 mV, 0 %."""
f = build_battery_frame(0, 0, 0)
validate_frame(f)
v100 = (f[3] << 8) | f[4]
assert v100 == 0
assert f[10] == 0
def test_total_frame_length(self):
"""Battery frame: SYNC(1)+LEN(1)+TYPE(1)+payload(8)+CRC(1) = 12 bytes."""
f = build_battery_frame(12000, 0, 80)
assert len(f) == 12
# ── Flight mode frame tests ────────────────────────────────────────────────
class TestFlightModeFrame:
def test_armed_text(self):
f = build_flight_mode_frame(True)
payload = f[3:-1]
assert payload == b'ARMED\x00'
def test_disarmed_text(self):
f = build_flight_mode_frame(False)
payload = f[3:-1]
assert payload == b'DISARM\x00'
def test_frame_type(self):
assert build_flight_mode_frame(True)[2] == 0x21
assert build_flight_mode_frame(False)[2] == 0x21
def test_crc_armed(self):
validate_frame(build_flight_mode_frame(True))
def test_crc_disarmed(self):
validate_frame(build_flight_mode_frame(False))
def test_armed_frame_length(self):
"""ARMED\0 = 6 bytes payload → total 10 bytes."""
f = build_flight_mode_frame(True)
assert len(f) == 10
def test_disarmed_frame_length(self):
"""DISARM\0 = 7 bytes payload → total 11 bytes."""
f = build_flight_mode_frame(False)
assert len(f) == 11
# ── CRC8 DVB-S2 self-test ─────────────────────────────────────────────────
class TestCRC8:
def test_known_vector(self):
"""Verify CRC8 DVB-S2 against known value (poly 0xD5, init 0)."""
# CRC of single byte 0xAB with poly 0xD5, init 0 → 0xC8
crc = crc8_dvb_s2(0, 0xAB)
assert crc == 0xC8
def test_different_payloads_differ(self):
f1 = build_battery_frame(12600, 0, 100)
f2 = build_battery_frame(11000, 0, 50)
assert f1[-1] != f2[-1], "different payloads should have different CRCs"
def test_crc_covers_type(self):
"""Changing the frame type changes the CRC."""
fa = build_battery_frame(12600, 0, 100) # type 0x08
fb = build_flight_mode_frame(True) # type 0x21
# Both frames differ in type byte and thus CRC
assert fa[-1] != fb[-1]
# ── battery_estimate_pct logic mirrored in Python ─────────────────────────
def battery_estimate_pct(voltage_mv: int) -> int:
"""Python mirror of battery_estimate_pct() in battery.c."""
if voltage_mv >= 13000:
v_min, v_max = 13200, 16800
else:
v_min, v_max = 9900, 12600
if voltage_mv <= v_min:
return 0
if voltage_mv >= v_max:
return 100
return int((voltage_mv - v_min) * 100 / (v_max - v_min))
class TestBatteryEstimatePct:
def test_3s_full(self):
assert battery_estimate_pct(12600) == 100
def test_3s_empty(self):
assert battery_estimate_pct(9900) == 0
def test_3s_mid(self):
pct = battery_estimate_pct(11250)
assert 45 <= pct <= 55
def test_4s_full(self):
assert battery_estimate_pct(16800) == 100
def test_4s_empty(self):
assert battery_estimate_pct(13200) == 0
def test_3s_over_voltage(self):
"""13000 mV triggers 4S branch (v_min=13200) → classified as dead 4S → 0%."""
assert battery_estimate_pct(13000) == 0
def test_zero_voltage(self):
assert battery_estimate_pct(0) == 0