"""
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:
    # ESP32-S3 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 ESP32-S3: 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):
        """ESP32-S3 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
