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