## 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>
238 lines
8.1 KiB
Python
238 lines
8.1 KiB
Python
"""
|
||
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: 0–100 %, 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
|