""" 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