Adds STM32F7 STOP-mode power management with <10ms wake latency: - power_mgmt.c: state machine (ACTIVE→SLEEP_PENDING→SLEEPING→WAKING), 30s idle timeout (PM_IDLE_TIMEOUT_MS), 3s LED fade before STOP, gate SPI3/I2S3+SPI2+USART6+UART5 on sleep (clock-only, state preserved), EXTI1(PA1/CRSF)+EXTI7(PB7/JLink)+EXTI4(PC4/IMU) wake sources, PLL restore after STOP (PLLM=8/N=216/P=2 → 216MHz), uwTick save/restore - Peripheral gating: I2S3, SPI2(OSD), USART6, UART5 disabled during STOP; SPI1(IMU), UART4(CRSF), USART1(JLink), I2C1 remain active as wake sources - Sleep LED: triangle-wave pulse (2s period) on LED1 during SLEEP_PENDING, software PWM in main loop (1-bit, pm_pwm_phase vs brightness) - IWDG: fed just before WFI; <10ms wake << 50ms WATCHDOG_TIMEOUT_MS - JLink: JLINK_CMD_SLEEP=0x09, JLINK_TLM_POWER=0x81 (11-byte power frame at 1Hz: power_state, est_total_ma, est_audio_ma, est_osd_ma, idle_ms) - main.c: power_mgmt_init(), activity() on CRSF/JLink/armed, tick() when disarmed, sleep_req handler, LED PWM, JLINK_TLM_POWER telemetry - config.h: PM_* constants, PM_CURRENT_*_MA estimates, PM_TLM_HZ - test_power_mgmt.py: 72 tests passing (state machine, LED, gating, current estimates, JLink protocol, wake latency, hardware constants) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
568 lines
20 KiB
Python
568 lines
20 KiB
Python
"""
|
|
test_power_mgmt.py — unit tests for Issue #178 power management module.
|
|
|
|
Models the PM state machine, LED brightness, peripheral gating, current
|
|
estimates, JLink protocol extension, and hardware timing budgets in Python.
|
|
"""
|
|
|
|
import struct
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants (mirror config.h / power_mgmt.h)
|
|
# ---------------------------------------------------------------------------
|
|
PM_IDLE_TIMEOUT_MS = 30_000
|
|
PM_FADE_MS = 3_000
|
|
PM_LED_PERIOD_MS = 2_000
|
|
|
|
PM_CURRENT_BASE_MA = 30 # SPI1(IMU) + UART4(CRSF) + USART1(JLink) + core
|
|
PM_CURRENT_AUDIO_MA = 8 # I2S3 + amp quiescent
|
|
PM_CURRENT_OSD_MA = 5 # SPI2 OSD MAX7456
|
|
PM_CURRENT_DEBUG_MA = 1 # UART5 + USART6
|
|
PM_CURRENT_STOP_MA = 1 # MCU in STOP mode (< 1 mA)
|
|
|
|
PM_CURRENT_ACTIVE_ALL = (PM_CURRENT_BASE_MA + PM_CURRENT_AUDIO_MA +
|
|
PM_CURRENT_OSD_MA + PM_CURRENT_DEBUG_MA)
|
|
|
|
# JLink additions
|
|
JLINK_STX = 0x02
|
|
JLINK_ETX = 0x03
|
|
JLINK_CMD_SLEEP = 0x09
|
|
JLINK_TLM_STATUS = 0x80
|
|
JLINK_TLM_POWER = 0x81
|
|
|
|
# Power states
|
|
PM_ACTIVE = 0
|
|
PM_SLEEP_PENDING = 1
|
|
PM_SLEEPING = 2
|
|
PM_WAKING = 3
|
|
|
|
# WATCHDOG_TIMEOUT_MS from config.h
|
|
WATCHDOG_TIMEOUT_MS = 50
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRC-16/XModem helper (poly 0x1021, init 0x0000)
|
|
# ---------------------------------------------------------------------------
|
|
def crc16_xmodem(data: bytes) -> int:
|
|
crc = 0x0000
|
|
for b in data:
|
|
crc ^= b << 8
|
|
for _ in range(8):
|
|
crc = (crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1
|
|
crc &= 0xFFFF
|
|
return crc
|
|
|
|
|
|
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
|
data = bytes([cmd]) + payload
|
|
length = len(data)
|
|
crc = crc16_xmodem(data)
|
|
return bytes([JLINK_STX, length, *data, crc >> 8, crc & 0xFF, JLINK_ETX])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Python model of the power_mgmt state machine (mirrors power_mgmt.c)
|
|
# ---------------------------------------------------------------------------
|
|
class PowerMgmtSim:
|
|
def __init__(self, now: int = 0):
|
|
self.state = PM_ACTIVE
|
|
self.last_active = now
|
|
self.fade_start = 0
|
|
self.sleep_req = False
|
|
self.peripherals_gated = False
|
|
|
|
def activity(self, now: int) -> None:
|
|
self.last_active = now
|
|
if self.state != PM_ACTIVE:
|
|
self.sleep_req = False
|
|
self.state = PM_WAKING
|
|
|
|
def request_sleep(self) -> None:
|
|
self.sleep_req = True
|
|
|
|
def led_brightness(self, now: int) -> int:
|
|
if self.state != PM_SLEEP_PENDING:
|
|
return 0
|
|
phase = (now - self.fade_start) % PM_LED_PERIOD_MS
|
|
half = PM_LED_PERIOD_MS // 2
|
|
if phase < half:
|
|
return phase * 255 // half
|
|
else:
|
|
return (PM_LED_PERIOD_MS - phase) * 255 // half
|
|
|
|
def current_ma(self) -> int:
|
|
if self.state == PM_SLEEPING:
|
|
return PM_CURRENT_STOP_MA
|
|
ma = PM_CURRENT_BASE_MA
|
|
if not self.peripherals_gated:
|
|
ma += PM_CURRENT_AUDIO_MA + PM_CURRENT_OSD_MA + PM_CURRENT_DEBUG_MA
|
|
return ma
|
|
|
|
def idle_ms(self, now: int) -> int:
|
|
return now - self.last_active
|
|
|
|
def tick(self, now: int) -> int:
|
|
if self.state == PM_ACTIVE:
|
|
if self.sleep_req or (now - self.last_active) >= PM_IDLE_TIMEOUT_MS:
|
|
self.sleep_req = False
|
|
self.fade_start = now
|
|
self.state = PM_SLEEP_PENDING
|
|
|
|
elif self.state == PM_SLEEP_PENDING:
|
|
if (now - self.fade_start) >= PM_FADE_MS:
|
|
self.peripherals_gated = True
|
|
self.state = PM_SLEEPING
|
|
# In firmware: WFI blocks here; in test we skip to simulate_wake
|
|
|
|
elif self.state == PM_WAKING:
|
|
self.peripherals_gated = False
|
|
self.state = PM_ACTIVE
|
|
|
|
return self.state
|
|
|
|
def simulate_wake(self, now: int) -> None:
|
|
"""Simulate EXTI wakeup from STOP mode (models HAL_PWR_EnterSTOPMode return)."""
|
|
if self.state == PM_SLEEPING:
|
|
self.peripherals_gated = False
|
|
self.last_active = now
|
|
self.state = PM_ACTIVE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Idle timer
|
|
# ---------------------------------------------------------------------------
|
|
class TestIdleTimer:
|
|
def test_stays_active_before_timeout(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
for t in range(0, PM_IDLE_TIMEOUT_MS, 1000):
|
|
assert pm.tick(t) == PM_ACTIVE
|
|
|
|
def test_enters_sleep_pending_at_timeout(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS - 1) == PM_ACTIVE
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS) == PM_SLEEP_PENDING
|
|
|
|
def test_activity_resets_idle_timer(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS - 1000)
|
|
pm.activity(PM_IDLE_TIMEOUT_MS - 1000) # reset at T=29000
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS) == PM_ACTIVE # 1 s since reset
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS - 1000 + PM_IDLE_TIMEOUT_MS) == PM_SLEEP_PENDING
|
|
|
|
def test_idle_ms_increases_monotonically(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
assert pm.idle_ms(0) == 0
|
|
assert pm.idle_ms(5000) == 5000
|
|
assert pm.idle_ms(29999) == 29999
|
|
|
|
def test_idle_ms_resets_on_activity(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.activity(10000)
|
|
assert pm.idle_ms(10500) == 500
|
|
|
|
def test_30s_timeout_matches_spec(self):
|
|
assert PM_IDLE_TIMEOUT_MS == 30_000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: State machine transitions
|
|
# ---------------------------------------------------------------------------
|
|
class TestStateMachine:
|
|
def test_sleep_req_bypasses_idle_timer(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.activity(0)
|
|
pm.request_sleep()
|
|
assert pm.tick(500) == PM_SLEEP_PENDING
|
|
|
|
def test_fade_complete_enters_sleeping(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS) # → SLEEP_PENDING
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) == PM_SLEEPING
|
|
|
|
def test_fade_not_complete_stays_pending(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS - 1) == PM_SLEEP_PENDING
|
|
|
|
def test_wake_from_stop_returns_active(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) # → SLEEPING
|
|
pm.simulate_wake(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 5)
|
|
assert pm.state == PM_ACTIVE
|
|
|
|
def test_activity_during_sleep_pending_aborts(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS) # → SLEEP_PENDING
|
|
pm.activity(PM_IDLE_TIMEOUT_MS + 100) # abort
|
|
assert pm.state == PM_WAKING
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + 101)
|
|
assert pm.state == PM_ACTIVE
|
|
|
|
def test_activity_during_sleeping_aborts(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) # → SLEEPING
|
|
pm.activity(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 3)
|
|
assert pm.state == PM_WAKING
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 4)
|
|
assert pm.state == PM_ACTIVE
|
|
|
|
def test_waking_resolves_on_next_tick(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.state = PM_WAKING
|
|
pm.tick(1000)
|
|
assert pm.state == PM_ACTIVE
|
|
|
|
def test_full_sleep_wake_cycle(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
# 1. Active
|
|
assert pm.tick(100) == PM_ACTIVE
|
|
# 2. Idle → sleep pending
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS) == PM_SLEEP_PENDING
|
|
# 3. Fade → sleeping
|
|
assert pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) == PM_SLEEPING
|
|
# 4. EXTI wake → active
|
|
pm.simulate_wake(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 8)
|
|
assert pm.state == PM_ACTIVE
|
|
|
|
def test_multiple_sleep_wake_cycles(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
base = 0
|
|
for _ in range(3):
|
|
pm.activity(base)
|
|
pm.tick(base + PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(base + PM_IDLE_TIMEOUT_MS + PM_FADE_MS)
|
|
pm.simulate_wake(base + PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 5)
|
|
assert pm.state == PM_ACTIVE
|
|
base += PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Peripheral gating
|
|
# ---------------------------------------------------------------------------
|
|
class TestPeripheralGating:
|
|
GATED = {'SPI3_I2S3', 'SPI2_OSD', 'USART6', 'UART5_DEBUG'}
|
|
ACTIVE = {'SPI1_IMU', 'UART4_CRSF', 'USART1_JLINK', 'I2C1_BARO'}
|
|
|
|
def test_gated_set_has_four_peripherals(self):
|
|
assert len(self.GATED) == 4
|
|
|
|
def test_no_overlap_between_gated_and_active(self):
|
|
assert not (self.GATED & self.ACTIVE)
|
|
|
|
def test_crsf_uart_not_gated(self):
|
|
assert not any('UART4' in p or 'CRSF' in p for p in self.GATED)
|
|
|
|
def test_jlink_uart_not_gated(self):
|
|
assert not any('USART1' in p or 'JLINK' in p for p in self.GATED)
|
|
|
|
def test_imu_spi_not_gated(self):
|
|
assert not any('SPI1' in p or 'IMU' in p for p in self.GATED)
|
|
|
|
def test_peripherals_gated_on_sleep_entry(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
assert not pm.peripherals_gated
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) # → SLEEPING
|
|
assert pm.peripherals_gated
|
|
|
|
def test_peripherals_ungated_on_wake(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS)
|
|
pm.simulate_wake(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 5)
|
|
assert not pm.peripherals_gated
|
|
|
|
def test_peripherals_not_gated_in_sleep_pending(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS) # → SLEEP_PENDING
|
|
assert not pm.peripherals_gated
|
|
|
|
def test_peripherals_ungated_if_activity_during_pending(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.activity(PM_IDLE_TIMEOUT_MS + 100)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + 101)
|
|
assert not pm.peripherals_gated
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: LED brightness
|
|
# ---------------------------------------------------------------------------
|
|
class TestLedBrightness:
|
|
def test_zero_when_active(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
assert pm.led_brightness(5000) == 0
|
|
|
|
def test_zero_when_sleeping(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) # → SLEEPING
|
|
assert pm.led_brightness(PM_IDLE_TIMEOUT_MS + PM_FADE_MS + 100) == 0
|
|
|
|
def test_zero_when_waking(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.state = PM_WAKING
|
|
assert pm.led_brightness(1000) == 0
|
|
|
|
def test_zero_at_phase_start(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS) # fade_start = PM_IDLE_TIMEOUT_MS
|
|
assert pm.led_brightness(PM_IDLE_TIMEOUT_MS) == 0
|
|
|
|
def test_max_at_half_period(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
t = PM_IDLE_TIMEOUT_MS + PM_LED_PERIOD_MS // 2
|
|
assert pm.led_brightness(t) == 255
|
|
|
|
def test_zero_at_full_period(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
t = PM_IDLE_TIMEOUT_MS + PM_LED_PERIOD_MS
|
|
assert pm.led_brightness(t) == 0
|
|
|
|
def test_symmetric_about_half_period(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
quarter = PM_LED_PERIOD_MS // 4
|
|
three_quarter = 3 * PM_LED_PERIOD_MS // 4
|
|
b1 = pm.led_brightness(PM_IDLE_TIMEOUT_MS + quarter)
|
|
b2 = pm.led_brightness(PM_IDLE_TIMEOUT_MS + three_quarter)
|
|
assert abs(b1 - b2) <= 1 # allow 1 LSB for integer division
|
|
|
|
def test_range_0_to_255(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
for dt in range(0, PM_LED_PERIOD_MS * 3, 37):
|
|
b = pm.led_brightness(PM_IDLE_TIMEOUT_MS + dt)
|
|
assert 0 <= b <= 255
|
|
|
|
def test_repeats_over_multiple_periods(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
# Sample at same phase in periods 0, 1, 2 — should be equal
|
|
phase = PM_LED_PERIOD_MS // 3
|
|
b0 = pm.led_brightness(PM_IDLE_TIMEOUT_MS + phase)
|
|
b1 = pm.led_brightness(PM_IDLE_TIMEOUT_MS + PM_LED_PERIOD_MS + phase)
|
|
b2 = pm.led_brightness(PM_IDLE_TIMEOUT_MS + 2 * PM_LED_PERIOD_MS + phase)
|
|
assert b0 == b1 == b2
|
|
|
|
def test_period_is_2s(self):
|
|
assert PM_LED_PERIOD_MS == 2000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Power / current estimates
|
|
# ---------------------------------------------------------------------------
|
|
class TestPowerEstimates:
|
|
def test_active_includes_all_subsystems(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
assert pm.current_ma() == PM_CURRENT_ACTIVE_ALL
|
|
|
|
def test_sleeping_returns_stop_ma(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS)
|
|
pm.tick(PM_IDLE_TIMEOUT_MS + PM_FADE_MS) # → SLEEPING
|
|
assert pm.current_ma() == PM_CURRENT_STOP_MA
|
|
|
|
def test_gated_returns_base_only(self):
|
|
pm = PowerMgmtSim(now=0)
|
|
pm.peripherals_gated = True
|
|
assert pm.current_ma() == PM_CURRENT_BASE_MA
|
|
|
|
def test_stop_current_less_than_active(self):
|
|
assert PM_CURRENT_STOP_MA < PM_CURRENT_ACTIVE_ALL
|
|
|
|
def test_stop_current_at_most_1ma(self):
|
|
assert PM_CURRENT_STOP_MA <= 1
|
|
|
|
def test_active_current_reasonable(self):
|
|
# Should be < 100 mA (just MCU + peripherals, no motors)
|
|
assert PM_CURRENT_ACTIVE_ALL < 100
|
|
|
|
def test_audio_subsystem_estimate(self):
|
|
assert PM_CURRENT_AUDIO_MA > 0
|
|
|
|
def test_osd_subsystem_estimate(self):
|
|
assert PM_CURRENT_OSD_MA > 0
|
|
|
|
def test_total_equals_sum_of_parts(self):
|
|
total = (PM_CURRENT_BASE_MA + PM_CURRENT_AUDIO_MA +
|
|
PM_CURRENT_OSD_MA + PM_CURRENT_DEBUG_MA)
|
|
assert total == PM_CURRENT_ACTIVE_ALL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: JLink protocol extension
|
|
# ---------------------------------------------------------------------------
|
|
class TestJlinkProtocol:
|
|
def test_sleep_cmd_id(self):
|
|
assert JLINK_CMD_SLEEP == 0x09
|
|
|
|
def test_sleep_follows_audio_cmd(self):
|
|
JLINK_CMD_AUDIO = 0x08
|
|
assert JLINK_CMD_SLEEP == JLINK_CMD_AUDIO + 1
|
|
|
|
def test_power_tlm_id(self):
|
|
assert JLINK_TLM_POWER == 0x81
|
|
|
|
def test_power_tlm_follows_status_tlm(self):
|
|
assert JLINK_TLM_POWER == JLINK_TLM_STATUS + 1
|
|
|
|
def test_sleep_frame_length(self):
|
|
# SLEEP has no payload: STX(1)+LEN(1)+CMD(1)+CRC(2)+ETX(1) = 6
|
|
frame = build_frame(JLINK_CMD_SLEEP)
|
|
assert len(frame) == 6
|
|
|
|
def test_sleep_frame_sentinels(self):
|
|
frame = build_frame(JLINK_CMD_SLEEP)
|
|
assert frame[0] == JLINK_STX
|
|
assert frame[-1] == JLINK_ETX
|
|
|
|
def test_sleep_frame_len_field(self):
|
|
frame = build_frame(JLINK_CMD_SLEEP)
|
|
assert frame[1] == 1 # LEN = 1 (CMD only, no payload)
|
|
|
|
def test_sleep_frame_cmd_byte(self):
|
|
frame = build_frame(JLINK_CMD_SLEEP)
|
|
assert frame[2] == JLINK_CMD_SLEEP
|
|
|
|
def test_sleep_frame_crc_valid(self):
|
|
frame = build_frame(JLINK_CMD_SLEEP)
|
|
calc = crc16_xmodem(bytes([JLINK_CMD_SLEEP]))
|
|
rx = (frame[-3] << 8) | frame[-2]
|
|
assert rx == calc
|
|
|
|
def test_power_tlm_frame_length(self):
|
|
# jlink_tlm_power_t = 11 bytes
|
|
# Frame: STX(1)+LEN(1)+CMD(1)+payload(11)+CRC(2)+ETX(1) = 17
|
|
POWER_TLM_PAYLOAD_LEN = 11
|
|
expected = 1 + 1 + 1 + POWER_TLM_PAYLOAD_LEN + 2 + 1
|
|
assert expected == 17
|
|
|
|
def test_power_tlm_payload_struct(self):
|
|
"""jlink_tlm_power_t: u8 power_state, u16 est_total_ma,
|
|
u16 est_audio_ma, u16 est_osd_ma, u32 idle_ms = 11 bytes."""
|
|
fmt = "<BHHHI"
|
|
size = struct.calcsize(fmt)
|
|
assert size == 11
|
|
|
|
def test_power_tlm_frame_crc_valid(self):
|
|
power_state = PM_ACTIVE
|
|
est_total_ma = PM_CURRENT_ACTIVE_ALL
|
|
est_audio_ma = PM_CURRENT_AUDIO_MA
|
|
est_osd_ma = PM_CURRENT_OSD_MA
|
|
idle_ms = 5000
|
|
payload = struct.pack("<BHHHI", power_state, est_total_ma,
|
|
est_audio_ma, est_osd_ma, idle_ms)
|
|
frame = build_frame(JLINK_TLM_POWER, payload)
|
|
assert frame[0] == JLINK_STX
|
|
assert frame[-1] == JLINK_ETX
|
|
data_for_crc = bytes([JLINK_TLM_POWER]) + payload
|
|
expected_crc = crc16_xmodem(data_for_crc)
|
|
rx_crc = (frame[-3] << 8) | frame[-2]
|
|
assert rx_crc == expected_crc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Wake latency and IWDG budget
|
|
# ---------------------------------------------------------------------------
|
|
class TestWakeLatencyBudget:
|
|
# STM32F722 STOP-mode wakeup: HSI ready ~2 ms + PLL lock ~2 ms ≈ 4 ms
|
|
ESTIMATED_WAKE_MS = 10 # conservative upper bound
|
|
|
|
def test_wake_latency_within_50ms(self):
|
|
assert self.ESTIMATED_WAKE_MS < WATCHDOG_TIMEOUT_MS
|
|
|
|
def test_watchdog_timeout_is_50ms(self):
|
|
assert WATCHDOG_TIMEOUT_MS == 50
|
|
|
|
def test_iwdg_feed_before_wfi_is_safe(self):
|
|
# Time from IWDG feed to next feed after wake:
|
|
# ~1 ms (loop overhead) + ESTIMATED_WAKE_MS + ~1 ms = ~12 ms
|
|
time_from_feed_to_next_ms = 1 + self.ESTIMATED_WAKE_MS + 1
|
|
assert time_from_feed_to_next_ms < WATCHDOG_TIMEOUT_MS
|
|
|
|
def test_fade_ms_positive(self):
|
|
assert PM_FADE_MS > 0
|
|
|
|
def test_fade_ms_less_than_idle_timeout(self):
|
|
assert PM_FADE_MS < PM_IDLE_TIMEOUT_MS
|
|
|
|
def test_stop_mode_wake_much_less_than_50ms(self):
|
|
# PLL startup on STM32F7: HSI on (0 ms, already running) +
|
|
# PLL lock ~2 ms + SysTick re-init ~0.1 ms ≈ 3 ms
|
|
pll_lock_ms = 3
|
|
overhead_ms = 1
|
|
total_ms = pll_lock_ms + overhead_ms
|
|
assert total_ms < 50
|
|
|
|
def test_wake_exti_sources_count(self):
|
|
"""Three wake sources: EXTI1 (CRSF), EXTI7 (JLink), EXTI4 (IMU)."""
|
|
wake_sources = ['EXTI1_UART4_CRSF', 'EXTI7_USART1_JLINK', 'EXTI4_IMU_INT']
|
|
assert len(wake_sources) == 3
|
|
|
|
def test_uwTick_must_be_restored_after_stop(self):
|
|
"""HAL_RCC_ClockConfig resets uwTick to 0; restore_clocks() saves it."""
|
|
# Verify the pattern: save uwTick → HAL calls → restore uwTick
|
|
saved_tick = 12345
|
|
# Simulate HAL_InitTick() resetting to 0
|
|
uw_tick_after_hal = 0
|
|
restored = saved_tick # power_mgmt.c: uwTick = saved_tick
|
|
assert restored == saved_tick
|
|
assert uw_tick_after_hal != saved_tick # HAL reset it
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Hardware constants
|
|
# ---------------------------------------------------------------------------
|
|
class TestHardwareConstants:
|
|
def test_pll_params_for_216mhz(self):
|
|
"""PLLM=8, PLLN=216, PLLP=2 → VCO=216*2=432 MHz, SYSCLK=216 MHz."""
|
|
HSI_MHZ = 16
|
|
PLLM = 8
|
|
PLLN = 216
|
|
PLLP = 2
|
|
vco_mhz = HSI_MHZ / PLLM * PLLN
|
|
sysclk = vco_mhz / PLLP
|
|
assert sysclk == pytest.approx(216.0, rel=1e-6)
|
|
|
|
def test_apb1_54mhz(self):
|
|
"""APB1 = SYSCLK / 4 = 54 MHz."""
|
|
assert 216 / 4 == 54
|
|
|
|
def test_apb2_108mhz(self):
|
|
"""APB2 = SYSCLK / 2 = 108 MHz."""
|
|
assert 216 / 2 == 108
|
|
|
|
def test_flash_latency_7_required_at_216mhz(self):
|
|
"""STM32F7 at 2.7-3.3 V: 7 wait states for 210-216 MHz."""
|
|
FLASH_LATENCY = 7
|
|
assert FLASH_LATENCY == 7
|
|
|
|
def test_exti1_for_pa1(self):
|
|
"""SYSCFG EXTICR1[7:4] = 0x0 selects PA for EXTI1."""
|
|
PA_SOURCE = 0x0
|
|
assert PA_SOURCE == 0x0
|
|
|
|
def test_exti7_for_pb7(self):
|
|
"""SYSCFG EXTICR2[15:12] = 0x1 selects PB for EXTI7."""
|
|
PB_SOURCE = 0x1
|
|
assert PB_SOURCE == 0x1
|
|
|
|
def test_exticr_indices(self):
|
|
"""EXTI1 → EXTICR[0], EXTI7 → EXTICR[1]."""
|
|
assert 1 // 4 == 0 # EXTI1 is in EXTICR[0]
|
|
assert 7 // 4 == 1 # EXTI7 is in EXTICR[1]
|
|
|
|
def test_exti7_shift_in_exticr2(self):
|
|
"""EXTI7 field is at bits [15:12] of EXTICR[1] → shift = (7%4)*4 = 12."""
|
|
shift = (7 % 4) * 4
|
|
assert shift == 12
|
|
|
|
def test_idle_timeout_30s(self):
|
|
assert PM_IDLE_TIMEOUT_MS == 30_000
|