Skip to content

Synced from upstream — do not edit this file directly

This page is generated from CARSSCenter/OpenNerve-Implantable-Pulse-Generator at Docs/WPT-Implementation-Report.md as of commit 7ab3cd3. To change it, edit the file upstream and re-run tools/sync_docs.py. Use the "Edit this page" link above to go straight to the upstream source.

WPT Wireless Charging — Firmware Implementation Report

Target hardware: Gen2 IPG PCBA MCU: STM32U585 Branch: feature/wireless-charging (merged into main) Firmware path: Gen2 PCBA/FW-MCU-H2/FW-NIH-MCU-H2/


1. Overview

The Gen2 IPG hardware is fully wired for wireless power transfer (WPT) charging. Prior to this implementation, all charging-related GPIO outputs were initialized low (chargers disabled), and the digital status signals were passively read into BLE advertisements without any firmware control. The device could not autonomously charge.

This implementation adds a complete autonomous charging state machine. When a WPT charging coil is brought near the device, the firmware detects the rectified voltage presence, transitions into a dedicated charging mode, controls the two battery charger ICs, monitors temperature and battery voltage, and advertises all status information over BLE — continuously, for the duration of the charging session.


2. Hardware Overview

2.1 Signal Path

Wireless coil → rectifier → VRECT rail
                                │
                         VRECT_DETn (PC7) ──── "Coil present" indicator (active low)
                         VRECT_OVPn (PC6) ──── Rectifier over-voltage flag (active low)
                         VRECT_MON_EN (PC10) ── Enable VRECT analog monitor
                         VRECT_MON (PC4) ──── Analog monitor output (ADC)
                                │
                         LTC3130 buck-boost converter
                         VCHG_DISABLE (PC8) ── Disable converter when HIGH
                         VCHG_PGOOD (PC9) ──── Converter output good flag
                                │
                    ┌──────────┴──────────┐
               Charger IC 1          Charger IC 2
               CHG1_EN (PE10)        CHG2_EN (PE11)    ← Enable
               CHG1_STATUS (PE14)    CHG2_STATUS (PE15) ← nCHRG open-drain (LOW=charging, HIGH-Z=done)
               CHG1_OVP_ERRn (PE12)  CHG2_OVP_ERRn (PE13) ← OVP error (active low)
                    │                        │
               Battery A               Battery B
               BATT_MON1 (PA0)         BATT_MON2 (PA1) ← Voltage monitor (÷2 divider)

2.2 Charge Rate Control

CHG_RATE1 (PE8) and CHG_RATE2 (PE9) control the charge current. The firmware sets: - CHG_RATE1 = HIGH - CHG_RATE2 = HIGH

This combination selects 100 mA charge rate. Other combinations are available but not used in production WPT mode.

These lines are set at the top of app_mode_wpt_handler() in App/Src/app_mode_wpt.c (lines 114–115):

HAL_GPIO_WritePin(CHG_RATE1_GPIO_Port, CHG_RATE1_Pin, GPIO_PIN_SET); /* HIGH */
HAL_GPIO_WritePin(CHG_RATE2_GPIO_Port, CHG_RATE2_Pin, GPIO_PIN_SET); /* HIGH → 100 mA */

To change the charge rate, modify these two HAL_GPIO_WritePin calls using the LTC4065 datasheet's RATE1/RATE2 truth table.

2.2a Charger IC Autonomous Operation (LTC4065)

The charger IC is the LTC4065, a standalone linear Li-Ion charger. CHGx_EN is held HIGH for the entire coil-present session and the IC manages its own charge cycle autonomously. The firmware never toggles CHGx_EN in response to charge status.

The LTC4065's nCHRG pin (connected directly to CHGx_STATUS with no inverting circuit) is an open-drain output with three states:

nCHRG state CHGx_STATUS GPIO reads Meaning
Pulled LOW by internal MOSFET GPIO_PIN_RESET (0) Battery is actively charging
HIGH-Z (floating, pulled to VCC by STM32 pull-up) GPIO_PIN_SET (1) Charge complete — current dropped to C/10 termination
Pulsing at 2 Hz Alternating 0/1 at 1 s sample rate Defective battery — voltage remained below 2.9 V for ¼ of charge time

After reaching C/10 termination, the IC enters standby and monitors the battery voltage internally. When the battery self-discharges sufficiently, the IC restarts the charge cycle automatically with no firmware interaction. nCHRG will go LOW again at that point.

The firmware reads CHGx_STATUS every second purely for telemetry — it is broadcast in the BLE advertisement. It does not gate any state transition, and the system does not enter WPT_PAUSED when one or both batteries report charge complete. CHGx_EN remains HIGH and the device stays in WPT_HIGH indefinitely while the coil is present; the LTC4065 autonomously manages termination and re-charge without any firmware involvement.

The only conditions that cause the firmware to intervene (pull CHGx_EN LOW and assert VCHG_DISABLE) are external protection faults that the IC cannot self-manage: over-temperature and VRECT overvoltage.

2.3 Thermistor Circuit

Three ADC channels work together to measure temperature:

Signal GPIO Role
THERM_REF PC2 Reference voltage injected into the thermistor divider
THERM_OUT PC1 Voltage at the thermistor/sense-resistor junction
THERM_OFST PC0 Offset correction voltage

The thermistor is a 104AP-2 NTC (10 kΩ at 25°C). A 49.9 kΩ sense resistor in series allows current to be inferred from the voltage drop.

2.4 Battery Voltage Monitoring

BATT_MON_EN (PA2) enables the battery monitor circuit. Each battery voltage is measured through a 2× voltage divider:

  • BATT_MON1 (PA0) → Battery A ÷ 2
  • BATT_MON2 (PA1) → Battery B ÷ 2

app_func_meas_batt_mon_meas() applies the BSP_BATT_FACTOR = 2 scale and returns voltages in millivolts.


3. Files Changed

3.1 New Files

File Purpose
App/Inc/app_mode_wpt.h Public interface: constants, function declarations
App/Src/app_mode_wpt.c Full charging state machine implementation

3.2 Modified Files

File Summary of Changes
Core/Src/gpio.c VCHG_DISABLE defaults LOW at boot (supports dead-battery cold-start); VRECT_DETn reconfigured as EXTI interrupt
Core/Src/stm32u5xx_it.c Added EXTI7_IRQHandler for VRECT_DETn (PC7)
App/Functions/Inc/app_func_state_machine.h Two new state constants; app_func_sm_vrect_coil_cb() declaration
App/Functions/Src/app_func_state_machine.c New app_func_sm_vrect_coil_cb(); WPT guard in app_func_sm_confirmation_timer_cb()
App/Src/app.c WPT cases in main switch; app_mode_wpt_timer_cb() in HAL_IncTick; dead-battery cold-start guard at top of app_init()
App/Src/app_mode_ble_active.c VRECT_DETn poll in main loop; WPT status bits 14–15 in MSD
App/Bsp/Src/bsp_magnet.c VRECT_DETn coil detection in existing EXTI callbacks
App/Config/app_config.h #include "app_mode_wpt.h" added
App/Src/app_state.c bsp_adc_init() added to wake sequence after STOP mode (see §6); VRECT_DETn cold-start poll added before STOP entry to handle coil-present-at-boot scenario
Core/Src/main.c MX_RTC_Init() hoisted before MX_GPIO_Init(); EOS guard added immediately after GPIO init to re-float BATT_SW_EN if EOS was previously confirmed (see §5.4)
App/Src/app_mode_ble_connection.c WPT cleanup block added after while loop: stops stimulation, disables sensors, power-cycles BLE chip on WPT_HIGH/WPT_PAUSED exit
App/Src/app_mode_wpt.c BLE-connected guard added at entry: waits for BLE_STATE_CONNECT bit to clear before calling app_func_ble_adv_start()

4. Application State Machine

4.1 New States

// App/Functions/Inc/app_func_state_machine.h
#define STATE_ACT_MODE_WPT_HIGH    0x0209U   // Coil present, charging active
#define STATE_ACT_MODE_WPT_PAUSED  0x020AU   // Coil present, charging paused

These slot into the existing state hierarchy alongside the other STATE_ACT_MODE_* values (0x0201–0x0208).

4.2 Complete State Map

STATE_SLEEP (0x0100)
    │  ▲                    │
    │  │ magnet             │ VRECT_DETn falling (coil present)
    ▼  │                    ▼
STATE_ACT (0x0200)    STATE_ACT_MODE_BLE_ACT (0x0201) ◄────────────────────┐
    │                       │  ▲                                             │
    └──────────────────────►│  │ VRECT_DETn rising (coil removed)           │
                            │  │                                             │
                            ▼  │                                             │
                   STATE_ACT_MODE_WPT_HIGH (0x0209)                         │
                            │  ▲                                             │
                            │  │ temp < 42°C AND VRECT_OVPn high            │
                            │  │ AND no OVP error                           │
                            ▼  │                                             │
                   STATE_ACT_MODE_WPT_PAUSED (0x020A) ──────────────────────┘
                                                        VRECT_DETn rising

4.3 WPT Entry: Coil Detected

Coil detection is dual-path for reliability:

Path 1 — EXTI interrupt (primary): VRECT_DETn (PC7) is configured as a rising+falling EXTI interrupt (EXTI7). When the rectifier activates, VRECT_DETn goes low. This triggers EXTI7_IRQHandlerHAL_GPIO_EXTI_IRQHandler()HAL_GPIO_EXTI_Falling_Callback() in bsp_magnet.c:

// App/Bsp/Src/bsp_magnet.c
void HAL_GPIO_EXTI_Falling_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == MAG_DET_Pin) {
        bsp_magnet_detect_cb(false);
    }
    else if (GPIO_Pin == VRECT_DETn_Pin) {
        app_func_sm_vrect_coil_cb(true);   // coil present
    }
}

EXTI7 fires even while the device is in STOP mode — the EXTI line remains active in low-power sleep, so a coil placement wakes the CPU directly.

Path 2 — Polling (belt-and-suspenders): In the BLE active mode main loop, VRECT_DETn is polled every ~50 ms:

// App/Src/app_mode_ble_active.c
if (HAL_GPIO_ReadPin(VRECT_DETn_GPIO_Port, VRECT_DETn_Pin) == GPIO_PIN_RESET) {
    app_func_sm_current_state_set(STATE_ACT_MODE_WPT_HIGH);
}

This is the final backstop in STATE_ACT_MODE_BLE_ACT: once BLE is initialized and the main loop is running, any coil presence that was not caught by EXTI will be caught here.

State machine callback:

// App/Functions/Src/app_func_state_machine.c
void app_func_sm_vrect_coil_cb(bool coil_present) {
    if (coil_present) {
        if ((curr_state == STATE_SLEEP || curr_state >= STATE_ACT)
                && curr_state != STATE_SHUTDOWN
                && curr_state != STATE_ACT_MODE_DVT) {
            if (curr_state == STATE_SLEEP) {
                /* Wake via BLE_ACT so BLE is fully cold-started before entering
                 * WPT mode. app_mode_ble_act_handler polls VRECT_DETn every 50 ms
                 * and will self-transition to STATE_ACT_MODE_WPT_HIGH immediately. */
                curr_state = STATE_ACT_MODE_BLE_ACT;
            } else {
                curr_state = STATE_ACT_MODE_WPT_HIGH;
            }
        }
    } else {
        if (curr_state == STATE_ACT_MODE_WPT_HIGH
         || curr_state == STATE_ACT_MODE_WPT_PAUSED) {
            curr_state = STATE_ACT_MODE_BLE_ACT;
        }
    }
}

When the coil is detected from STATE_SLEEP, the device routes through STATE_ACT_MODE_BLE_ACT rather than jumping directly to STATE_ACT_MODE_WPT_HIGH. This is necessary because BLE must be fully cold-started (nRF52810 powered and initialized) before entering WPT mode, which also advertises over BLE. app_mode_ble_act_handler polls VRECT_DETn every 50 ms and transitions to WPT_HIGH immediately on the first poll pass.

The coil-detect path will not interrupt DVT mode or the SHUTDOWN state. All other states yield to the charger, but the behaviour differs by state:

  • STATE_SLEEP — routes through STATE_ACT_MODE_BLE_ACT first so BLE cold-starts before WPT mode is entered.
  • STATE_ACT_MODE_BLE_ACT — transitions directly to WPT_HIGH; BLE is already in advertising mode, no teardown needed.
  • STATE_ACT_MODE_BLE_CONN — transitions to WPT_HIGH, then app_mode_ble_conn_handler() runs a cleanup block on exit: any active manual stimulation is stopped via app_mode_therapy_stop(), any active sensor sampling is disabled, and app_func_ble_enable(false) power-cycles the nRF52810 to immediately terminate the BLE link-layer connection. The WPT handler then calls app_func_ble_enable(true) to cold-start BLE fresh before advertising.
  • All other active sub-states (therapy session, impedance test, battery test, etc.) — transition to WPT_HIGH directly; the respective handler exits its while loop on the next iteration and calls its normal cleanup.

4.4 WPT Exit: Coil Removed

When the coil is removed, VRECT_DETn rises. The rising EXTI fires HAL_GPIO_EXTI_Rising_Callback(), which calls app_func_sm_vrect_coil_cb(false), setting state back to STATE_ACT_MODE_BLE_ACT. The while() condition in app_mode_wpt_handler() then fails on the next iteration, the handler runs its exit block (disables chargers), and app_handler() dispatches to app_mode_ble_act_handler() on the following loop.

4.5 Battery/Impedance Test Suppression

The RTC-based confirmation timer normally triggers scheduled STATE_ACT_MODE_BATT_TEST and STATE_ACT_MODE_IMPED_TEST transitions. During WPT charging, battery voltage is elevated by the charging current, which would produce false ER/EOS readings. A guard was added:

// App/Functions/Src/app_func_state_machine.c
void app_func_sm_confirmation_timer_cb(void) {
    if (curr_state == STATE_ACT_MODE_WPT_HIGH
     || curr_state == STATE_ACT_MODE_WPT_PAUSED) {
        return;   // suppress test transitions during charging
    }
    // ... rest of timer logic
}

The active-mode EOS check (app_func_sm_active_eos_check()) applies the same suppression via its own state guard — it returns immediately without reading or modifying the counters when called while in STATE_ACT_MODE_WPT_HIGH or STATE_ACT_MODE_WPT_PAUSED.

4.6 EOS Counter Clear on Successful Charge

A device that reached EOS before being placed on a charger would otherwise re-enter EOS sleep immediately after the charging session ends, because the ER/EOS counters in RTC_BKP_DR1/DR2 are still set. To handle this, app_mode_wpt_handler() reads the battery voltage at the exit block (after the converter has been disabled, so the reading is clean battery voltage unaffected by charging current) and clears both counters if the highest battery voltage exceeds HPID_BATTERY_ER_LEVEL:

// App/Src/app_mode_wpt.c — exit block
_Float64 er_level_v = 0.0;
app_func_para_data_get((const uint8_t*)HPID_BATTERY_ER_LEVEL, ...);
uint16_t er_level_mv = (uint16_t)(er_level_v * 1000.0);
uint16_t exit_vbat[2] = {0U, 0U};
app_func_meas_batt_mon_meas(&exit_vbat[0], &exit_vbat[1]);
uint16_t exit_vbat_max = MAX(exit_vbat[0], exit_vbat[1]);
if (exit_vbat_max > er_level_mv) {
    HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, 0U);
    HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR2, 0U);
}

The ER threshold (higher than EOS) is used deliberately so that a brief or partial charge session that only marginally recovers the battery does not prematurely clear the EOS flag. If the coil is removed before the battery reaches the ER threshold, the counters remain set and the device will re-enter EOS sleep.


5. Boot-Time Behavior: VCHG_DISABLE and Dead-Battery Cold-Start

5.1 Problem

When the device battery is fully depleted, the only power source available at startup is the wireless charger via VCHG_RAIL. If VCHG_DISABLE is driven HIGH immediately on boot (disabling the LTC3130), VCHG_RAIL collapses, the MCU loses power, and the device resets — creating an infinite boot loop that prevents a dead battery from ever being charged.

5.2 Solution

VCHG_DISABLE is now initialized LOW (converter enabled) in gpio.c, allowing VCHG_RAIL to remain live through the entire startup sequence. app_init() then performs a one-time check before any other initialization:

// Core/Src/gpio.c
/* VCHG_DISABLE starts LOW (converter enabled) to support dead-battery WPT cold-start.
 * app_init() gates it off ~500 ms later if no coil is detected. */
HAL_GPIO_WritePin(VCHG_DISABLE_GPIO_Port, VCHG_DISABLE_Pin, GPIO_PIN_RESET);
// App/Src/app.c — top of app_init(), before bsp_sp_init()
/* Dead-battery WPT cold-start guard.
 * VCHG_DISABLE was held LOW since gpio.c init so that VCHG_RAIL stays alive
 * if the device is powered by a wireless charger with a depleted battery.
 * Wait ~500 ms then check VRECT_DETn. If no coil is present, disable the
 * converter. Kick IWDG mid-delay since the refresh LPTIM is not yet running. */
HAL_Delay(250);
HAL_IWDG_Refresh(&hiwdg);
HAL_Delay(250);
if (HAL_GPIO_ReadPin(VRECT_DETn_GPIO_Port, VRECT_DETn_Pin) == GPIO_PIN_SET) {
    /* VRECT_DETn HIGH = no coil present. Disable converter, continue on battery. */
    HAL_GPIO_WritePin(VCHG_DISABLE_GPIO_Port, VCHG_DISABLE_Pin, GPIO_PIN_SET);
}
/* If VRECT_DETn LOW (coil present), leave VCHG enabled.
 * The state machine will enter WPT mode and manage VCHG_DISABLE from there. */

5.3 Behavior by Scenario

Scenario Result
Dead battery (non-EOS) + charger present VCHG_DISABLE stays LOW → VCHG_RAIL sustained → MCU boots fully → WPT mode entered normally
EOS battery + charger present at boot VCHG_DISABLE stays LOW; MCU boots; EOS guard re-floats BATT_SW_EN; VRECT_DETn poll in sleep handler detects coil → WPT mode entered without cycling coil (see §5.4)
Battery OK + no charger After 500 ms, VRECT_DETn HIGH → VCHG_DISABLE driven HIGH → converter off, normal operation
Battery OK + charger present VRECT_DETn LOW → VCHG stays enabled → WPT mode entered via state machine

The IWDG is initialized by MX_IWDG_Init() before app_init() is called, but the LPTIM-based watchdog refresh timer has not yet started. The two 250 ms half-delays with an explicit HAL_IWDG_Refresh() between them ensure the watchdog does not expire during the startup check.

VCHG_DISABLE = HIGH means the converter is disabled. After the startup check, the WPT handler drives it LOW when a coil is confirmed present and charging should proceed (see §7.1), and returns it HIGH on exit or pause.

5.4 EOS Cold-Start: BATT_SW_EN Guard and VRECT_DETn Poll

When a device has reached EOS and the discharge capacitor has fully drained (batteries physically disconnected), placing a WPT coil powers the MCU via the dead-battery path (§5.2). On this boot, VRECT_DETn is already LOW — no falling edge fires, so the EXTI never calls app_func_sm_vrect_coil_cb(true). app_func_sm_init() then sees eos_counter >= COUNT_MAX_EOS and sets STATE_SLEEP. Without a further fix, the device would enter the sleep STOP loop and sit there indefinitely with the coil present, requiring the user to remove and re-place the coil to generate the transition edge.

Two changes address this:

Fix 1 — EOS guard in Core/Src/main.c: MX_RTC_Init() was hoisted to before MX_GPIO_Init(). A guard block was added immediately after MX_GPIO_Init(): if RTC_BKP_DR2 >= COUNT_MAX_EOS, BATT_SW_EN is immediately re-floated (GPIO_MODE_ANALOG / GPIO_NOPULL), overriding the default HIGH that MX_GPIO_Init() drives. This eliminates the window during which the pin fights the discharge capacitor on every BOR or TPS3831 reset loop.

Fix 2 — VRECT_DETn poll in App/Src/app_state.c: A poll of VRECT_DETn was added to app_state_sleep_handler(), placed after the BATT_SW_EN EOS float and after the LPTIMs are started, but before the STOP while loop:

/* Cold-start guard: if a WPT coil is already present when entering sleep,
 * the VRECT_DETn falling edge was missed during boot (MCU was fully off and
 * the coil was placed before the EXTI was armed). Poll the pin directly so
 * WPT mode is entered without requiring the user to cycle the coil. */
if (HAL_GPIO_ReadPin(VRECT_DETn_GPIO_Port, VRECT_DETn_Pin) == GPIO_PIN_RESET) {
    app_func_sm_vrect_coil_cb(true);
}

If the coil is present, app_func_sm_vrect_coil_cb(true) changes state to STATE_ACT_MODE_BLE_ACT. The while loop's condition is immediately false, so STOP mode is never entered. The wakeup restore code then runs: BATT_SW_EN is driven HIGH (reconnecting the batteries), clocks are restored, FRAM and SPI are re-initialized, and EVENT_WAKEUP is logged. app_handler() dispatches to app_mode_ble_act_handler(), which polls VRECT_DETn every 50 ms and transitions directly to STATE_ACT_MODE_WPT_HIGH. From there the WPT handler enables the chargers and charging proceeds normally. When the coil is removed and battery voltage is above HPID_BATTERY_ER_LEVEL, the WPT exit block clears RTC_BKP_DR1/DR2, un-asserting EOS for the next boot.


6. STOP Mode Wake Known Issue: ADC Reinitialization Required

After exiting STOP mode, the STM32U585 ADC loses its calibration factors. The first thing app_mode_ble_act_handler() does — before powering on the BLE chip — is call app_mode_ble_act_adv_msd_update(), which calls bsp_adc_single_sampling() eight times (DVDD, two battery voltages, two impedances, three thermistor voltages). Each call goes through bsp_adc_sampling(), which polls while(!sampling.isCompleted) waiting for the GPDMA transfer-complete callback. If the ADC is uncalibrated, the conversion never completes, the DMA interrupt never fires, and the loop spins forever.

Because TIM6's period-elapsed ISR calls bsp_wdg_refresh() during ADC sampling, the IWDG never expires. The device hangs at elevated current draw, app_func_ble_enable(true) is never reached, and BLE_PWRn stays HIGH — no BLE advertisements appear and the charger never transitions to yellow-light (active charge) mode.

This fix is not yet implemented. If the ADC hang described above is observed in practice (device appears to boot after STOP wakeup but BLE advertising never starts), the recommended fix is to add bsp_adc_init() to the STOP mode wake sequence in app_state_sleep_handler(), after bsp_sp_init(), mirroring what app_init() does at cold boot:

// App/Src/app_state.c — post-STOP wake sequence (recommended fix)
SystemClock_Config();
__HAL_PWR_CLEAR_FLAG(PWR_FLAG_STOPF);
HAL_ResumeTick();

bsp_wdg_refresh();
bsp_sp_init(&app_func_command_parser, &bsp_fram_write_cplt_cb);
bsp_fram_init(&app_func_logs_write_cplt_cb);
bsp_adc_init();   // ← recalibrate ADC1/ADC4 and relink DMA after STOP mode

bsp_adc_init() runs HAL_ADCEx_Calibration_Start() on both ADC1 and ADC4, reinitializes the GPDMA handles, and samples the internal voltage reference to establish vrefanalog_mv. With this in place the ADC/DMA subsystem would be in the same state as after a cold-boot app_init(), and the wake-from-sleep WPT charging path would work identically to the reset path.


7. WPT Handler Deep Dive

app_mode_wpt_handler() in App/Src/app_mode_wpt.c owns the entire charging session. It is called from app_handler() when the state is STATE_ACT_MODE_WPT_HIGH or STATE_ACT_MODE_WPT_PAUSED, and it does not return until the coil is removed.

7.1 Entry Sequence

1. CHG_RATE1 = HIGH, CHG_RATE2 = HIGH       → select 100 mA charge rate
2. VRECT_MON_EN = HIGH                       → enable VRECT voltage monitor
3. VCHG_DISABLE = LOW                        → enable boost converter
4. app_func_meas_therm_enable(true)          → power up thermistor circuit
5. app_func_meas_batt_mon_enable(true)       → power up battery monitor
6. Sample BATT_MON1 and BATT_MON2
   - If ≥ 1000 mV → battery_x_present = true
   - If  < 1000 mV → battery_x_present = false (no charger enabled)
7. CHG1_EN = HIGH if battery A present
   CHG2_EN = HIGH if battery B present
8. app_func_ble_enable(true)                 → cold-start nRF52810
   - If entering from STATE_ACT_MODE_BLE_CONN, the conn handler will have
     already called app_func_ble_enable(false) during cleanup, so this is
     a true cold-start. From all other paths BLE may already be running,
     but app_func_ble_enable() is idempotent (power-cycles then reinits).
   - Guard: if BLE_STATE_CONNECT bit is still set after enable(true),
     poll until it clears (up to 3 s) before proceeding to advertising.
9. Start BLE advertising (see §7.4)

7.2 Main Loop (every 50 ms iteration, sample every 1 s)

The wpt_ms_timer static variable is decremented in app_mode_wpt_timer_cb(), which is called from HAL_IncTick() at 1 ms intervals. When it reaches zero, the 1-second measurement block runs and the timer resets to 1000.

Per-second measurement block:

1. app_func_meas_batt_mon_meas()    → vbat[0], vbat[1] in mV  (for broadcast only)
2. app_func_meas_therm_meas() ×3   → THERM_REF, THERM_OUT, THERM_OFST in mV
3. app_mode_wpt_calc_temperature()  → temp_c (float, °C)
4. Battery absence detection         → per-battery 2-strike rule (1 V threshold)
5. Read CHG1/2_OVP_ERRn, VRECT_OVPn, CHG1/2_STATUS via HAL_GPIO_ReadPin
6. Evaluate state transition (§6.3) → driven by CHGx_STATUS, temp, OVP only
7. app_mode_ble_act_adv_msd_update() → refresh BLE advertisement

Battery voltage is measured every second and appears in the BLE advertisement (MSD bytes 1–2), but it does not drive any charging state decision. The 1 V threshold used in battery absence detection is only for determining whether a physical battery is connected at all — it is not used to infer charge state or to trigger HIGH↔PAUSED transitions.

Between sample ticks:

app_func_ble_new_state_get()
bsp_sp_cmd_handler()
HAL_Delay(50)
If the BLE advertisement burst ends (BLE_STATE_ADV_STOP), a new burst is immediately started with refreshed MSD.

7.3 Charging State Transitions

WPT_HIGH → WPT_PAUSED

Only external protection faults trigger a pause. Charge completion is not a firmware pause trigger — the LTC4065 handles termination and re-charge autonomously with CHGx_EN held HIGH.

Condition Signal / Logic
Battery A overvoltage error CHG1_OVP_ERRn = LOW and battery_a_present
Battery B overvoltage error CHG2_OVP_ERRn = LOW and battery_b_present
Temperature too high temp_c > 42.0°C
Rectifier overvoltage VRECT_OVPn = LOW

On entering PAUSED:

VCHG_DISABLE = HIGH    (disable converter)
CHG1_EN = LOW
CHG2_EN = LOW
curr_state = STATE_ACT_MODE_WPT_PAUSED

WPT_PAUSED → WPT_HIGH

Resume requires that the protection faults have cleared, at least one battery is present, and the OVP hold timer has expired. The firmware does not consult CHGx_STATUS for resume decisions — the LTC4065 autonomously manages its own charge cycle (including re-charge after self-discharge) once CHGx_EN is asserted and VCHG_DISABLE is de-asserted.

Battery voltage is measured and broadcast in the BLE advertisement every second but does not gate any state transition.

Condition Logic
At least one battery present battery_a_present OR battery_b_present
Temperature below threshold temp_c < 42.0°C
No rectifier overvoltage VRECT_OVPn = HIGH
OVP pause hold expired wpt_paused_hold_ms == 0 (always 0 unless a VRECT OVP fault caused the pause; see WPT_OVP_PAUSE_HOLD_MS)

On resuming:

VCHG_DISABLE = LOW     (enable converter)
CHG1_EN = HIGH         (if battery A present)
CHG2_EN = HIGH         (if battery B present)
curr_state = STATE_ACT_MODE_WPT_HIGH

Once CHGx_EN is HIGH and the converter is enabled, the LTC4065 takes over: it charges at the programmed rate, terminates at C/10, and autonomously re-initiates a charge cycle if the battery self-discharges below its internal re-charge threshold.

7.4 BLE Advertising During WPT

WPT mode uses the same BLE infrastructure as BLE active mode. app_mode_ble_act_adv_msd_update() is called to populate the MSD, which means every BLE scan of a charging device returns live measurements: battery voltages, thermistor readings, impedance, DVDD, and all GPIO status flags.

The advertisement timeout is set to 1 second (wpt_adv_interval_s = 1), matching the measurement cadence. The BLE module advertises a 1-second burst, stops, the firmware calls app_mode_ble_act_adv_msd_update() with the freshest data, then restarts the burst. This gives any observing host roughly one fresh advertisement per second.

MSD Byte Layout

The MSD payload is 24 bytes total. Bytes 0–7 carry analog measurements; bytes 8–9 are the 16-bit GPIO/state status word.

Bytes 0–7 — Analog measurements:

Byte Signal Units Notes
0 DVDD voltage × 100 mV e.g. 0x0C = 1.2 V
1 Battery A voltage × 100 mV e.g. 0x28 = 4.0 V
2 Battery B voltage × 100 mV
3 Impedance A × 10 mV
4 Impedance B × 10 mV
5 Thermistor REF × 10 mV
6 Thermistor OUT × 10 mV
7 Thermistor OFST × 10 mV

Bytes 8–9 — Status bits (16-bit little-endian):

Bit Signal 1 means 0 means
0 VRECT_DETn No coil present Coil present (active low)
1 VRECT_OVPn Rectifier voltage OK Rectifier OVP fault
2 VCHG_PGOOD Converter output good Converter off / fault
3 CHG1_STATUS (nCHRG) Battery A idle / charge complete Battery A actively charging
4 CHG1_OVP_ERRn Battery A OVP OK Battery A OVP error
5 CHG2_STATUS (nCHRG) Battery B idle / charge complete Battery B actively charging
6 CHG2_OVP_ERRn Battery B OVP OK Battery B OVP error
7 ENG2_SDNn ENG2 enabled ENG2 shutdown
8 ENG1_SDNn ENG1 enabled ENG1 shutdown
9 ECG_RLD ECG RLD enabled ECG RLD off
10 ECG_HR_SDNn ECG HR enabled ECG HR shutdown
11 ECG_RR_SDNn ECG RR enabled ECG RR shutdown
12 TEMP_EN Thermistor powered Thermistor off
13 IMP_EN Impedance measurement enabled Off
14 WPT_ACTIVE Device in WPT_HIGH or WPT_PAUSED Not in WPT mode
15 WPT_PAUSED Device in WPT_PAUSED specifically Not paused

Example decode — status bytes 0x7B 0x10:

0x7B = 0111 1011 → bits 0,1,3,4,5,6 set: VRECT_DETn(no coil), VRECT_OVPn(OK), CHG1_STATUS(idle), CHG1_OVP_ERRn(OK), CHG2_STATUS(idle), CHG2_OVP_ERRn(OK) 0x10 = 0001 0000 → bit 12 set: TEMP_EN(thermistor powered)

Interpretation: device idle in BLE active mode, no coil present, thermistor powered, both chargers idle, all protection signals healthy.

7.5 Exit Sequence

Triggered when curr_state is no longer WPT_HIGH or WPT_PAUSED (set by the VRECT rising-edge EXTI callback):

VCHG_DISABLE = HIGH    (disable converter)
CHG1_EN = LOW
CHG2_EN = LOW
VRECT_MON_EN = LOW     (disable VRECT monitor)
app_func_ble_enable(false)

8. Battery Absence Detection

The "2-strike" rule prevents spurious absence detection from a single noisy ADC reading:

if (vbat[x] < 1000 mV) {
    battery_x_low_count++;
    if (battery_x_low_count >= 2 && battery_x_present) {
        battery_x_present = false;
        CHGx_EN = LOW;    // stop charging this slot
    }
} else {
    battery_x_low_count = 0;   // reset on any good reading
}
  • Two consecutive samples below 1 V are required before a battery is declared absent.
  • A single good reading resets the counter — there is no hysteresis on the other side.
  • If both batteries become absent, the device stays in WPT state (coil still present) but does not attempt charging. The chargers are disabled and the device continues advertising.

9. Temperature Measurement

9.1 Circuit Model

THERM_REF ──── 49.9 kΩ sense ──── THERM_OUT ──── NTC thermistor ──── THERM_OFST (ground ref)

All three voltages are measured by the ADC and passed to app_mode_wpt_calc_temperature().

9.2 Resistance Calculation

float voltage      = therm_out_mv - therm_ofst_mv;   // voltage across thermistor
float voltage_drop = therm_ref_mv - therm_out_mv;    // voltage across 49.9 kΩ sense resistor
float current      = voltage_drop / 49900.0f;         // current through series circuit
float resistance   = voltage / current;               // thermistor resistance in Ω

If either voltage or voltage_drop is ≤ 0 (thermistor unpowered or shorted), the function returns 0.0°C and charging is not paused on temperature grounds.

9.3 Lookup Table and Interpolation

The 104AP-2 NTC characteristic is stored as a 5-point table (ported from the OpenNerve charger firmware):

Temperature (°C) Resistance (Ω)
20 126,400
25 100,000
30 79,590
40 51,320
50 33,790

Temperature is found by linear interpolation between the two bracketing entries:

float frac = (r_hi - resistance) / (r_hi - r_lo);
return t_lo + frac * (t_hi - t_lo);

Values outside the table range (> 126,400 Ω → < 20°C, or < 33,790 Ω → > 50°C) are clamped to the table endpoints.

Threshold check: 42.0°C corresponds to approximately 47,814 Ω (interpolated between the 40°C and 50°C entries).


10. GPIO Reference Table

All pins relevant to WPT, with their port, pin number, direction, and active level:

Signal Port Pin Direction Active Level Notes
VRECT_DETn PC 7 Input / EXTI LOW = coil present EXTI7, rising+falling
VRECT_OVPn PC 6 Input LOW = OVP fault Pulled up
VRECT_MON_EN PC 10 Output HIGH = enabled Enable VRECT ADC path
VRECT_MON PC 4 Analog ADC input
VCHG_DISABLE PC 8 Output HIGH = disabled Starts LOW at boot; app_init() drives HIGH ~500 ms later if no coil detected
VCHG_PGOOD PC 9 Input HIGH = good No pull
CHG1_EN PE 10 Output HIGH = enabled Starts LOW
CHG1_STATUS PE 14 Input HIGH = complete Pulled up
CHG1_OVP_ERRn PE 12 Input LOW = OVP error Pulled up
CHG2_EN PE 11 Output HIGH = enabled Starts LOW
CHG2_STATUS PE 15 Input HIGH = complete Pulled up
CHG2_OVP_ERRn PE 13 Input LOW = OVP error Pulled up
CHG_RATE1 PE 8 Output LOW for 50 mA
CHG_RATE2 PE 9 Output HIGH for 50 mA
BATT_MON1 PA 0 Analog Battery A ÷2
BATT_MON2 PA 1 Analog Battery B ÷2
BATT_MON_EN PA 2 Output HIGH = enabled
THERM_REF PC 2 Analog ADC reference
THERM_OUT PC 1 Analog ADC NTC junction
THERM_OFST PC 0 Analog ADC ground offset
TEMP_EN PC 5 Output HIGH = enabled Powers thermistor circuit

11. Interrupt and Timer Architecture

11.1 EXTI7 (VRECT_DETn)

Hardware edge on PC7
    │
    ▼
EXTI7_IRQHandler()                 [Core/Src/stm32u5xx_it.c]
    │
    ▼
HAL_GPIO_EXTI_IRQHandler(VRECT_DETn_Pin)
    │
    ├─ falling edge ──► HAL_GPIO_EXTI_Falling_Callback()  [App/Bsp/Src/bsp_magnet.c]
    │                       └──► app_func_sm_vrect_coil_cb(true)
    │
    └─ rising edge  ──► HAL_GPIO_EXTI_Rising_Callback()   [App/Bsp/Src/bsp_magnet.c]
                            └──► app_func_sm_vrect_coil_cb(false)

EXTI3 (MAG_DET) and EXTI7 (VRECT_DETn) share the same callback functions; each is dispatched by pin ID.

11.2 WPT 1-Second Sample Timer

SysTick (1 ms)
    │
    ▼
HAL_IncTick()                      [App/Src/app.c]
    │
    ├──► app_mode_ble_act_timer_cb()    (BLE active timeout)
    ├──► app_mode_ble_conn_timer_cb()   (BLE connection timeout)
    ├──► app_mode_dvt_acc_timer_cb()    (DVT accelerometer)
    └──► app_mode_wpt_timer_cb()        (WPT 1-second sample timer)
                │
                ▼
         wpt_ms_timer-- (until 0)

The WPT timer callback runs every 1 ms regardless of current application state — it simply decrements a counter. The counter is only reset inside app_mode_wpt_handler(), so there is no concern about it counting while in other modes.


12. Constants Reference

All thresholds are defined in App/Inc/app_mode_wpt.h:

#define WPT_BATT_ABSENT_THRESHOLD_MV   1000U    // < 1.0 V → battery absent (presence detection only)
#define WPT_BATT_ABSENT_CONSECUTIVE    2U        // 2 consecutive samples required to declare absent
#define WPT_THERM_PAUSE_THRESHOLD_C    42.0f    // > 42°C → pause charging
#define WPT_THERM_RESUME_THRESHOLD_C   42.0f    // < 42°C → may resume (protection condition, not IC-driven)
#define WPT_OVP_PAUSE_HOLD_MS          5000U    // ms to hold in PAUSED after a VRECT OVP event before allowing resume
#define WPT_THERM_SENSE_RESISTOR_OHM   49900.0f // 49.9 kΩ series sense resistor

Battery voltage is not used to gate charge state transitions. The WPT_BATT_ABSENT_THRESHOLD_MV constant is used solely for detecting whether a physical battery is present at all (both at handler entry and during the 2-strike absence check in the main loop). Charge-complete and resume decisions are managed autonomously by the LTC4065; the firmware only intervenes for thermal and OVP protection faults.


13. Known Limitations and Future Considerations

  • No hysteresis on thermal resume: The pause and resume thresholds are both 42.0°C. In a real thermal environment this may cause repeated pause/resume cycling near the threshold. Consider adding a lower resume threshold (e.g., 40°C) if this is observed in testing.

  • No deep sleep during WPT: The WPT handler's main loop calls HAL_Delay(50) continuously. The device does not enter LP sleep during a charging session. This is by design (continuous BLE advertising required) but means higher MCU power draw during charging.

  • BLE command parsing during WPT: The WPT handler does not call app_func_command_req_parser_set(). Whichever parser was active before WPT mode (typically the BLE active mode parser) remains set. This means an authenticated BLE connection during WPT mode will transition the state machine to STATE_ACT_MODE_BLE_CONN, which will exit the WPT handler's while loop and trigger the clean exit sequence (charging disabled, BLE disabled). If BLE connectivity during charging is required, the WPT mode's command handling should be revisited.

  • VRECT_MON ADC not yet consumed: VRECT_MON_EN is asserted during WPT mode but the VRECT_MON ADC channel is not sampled by the WPT handler. The rectified voltage is not yet reported in the BLE advertisement. This could be added as a future enhancement.

  • Charger applied during active BLE connection — fixed: When a charger is detected while in STATE_ACT_MODE_BLE_CONN, a cleanup block now runs after app_mode_ble_conn_handler() exits its while loop. If manual stimulation was active (stim_en), app_mode_therapy_stop() is called. If sensor sampling was active (sens_en), all sensor channels are disabled. Then app_func_ble_enable(false) is called, which immediately power-cycles the nRF52810 and terminates the BLE link-layer connection at the hardware level — reliably ending the host's connection regardless of protocol state. The WPT handler's app_func_ble_enable(true) call then performs a clean cold-start before advertising begins. A secondary guard in app_mode_wpt_handler() checks for the BLE_STATE_CONNECT bit after app_func_ble_enable(true) and polls until it clears, providing a safety net against any future path that enters WPT from a connected state. Validated in testing: stimulation stops within one main-loop iteration (~1 s) and WPT BLE advertising begins promptly thereafter.

  • STATE_ACT_MODE_THERAPY_SESSION → WPT during stimulation initialization (deferred, not yet fixed): If VRECT_DETn fires while app_mode_therapy_start() is mid-execution (during one of the three HAL_Delay(100) setup calls or the timer initialization sequence), curr_state will flip to WPT_HIGH before the therapy handler's while loop is entered. When control returns to app_mode_therapy_handler(), the while loop condition is immediately false, and app_mode_therapy_stop() is called — which is the correct cleanup path. However, app_func_stim_off() is being called on partially-initialized stimulation hardware (HV supply may be on, DAC may be mid-ramp, timers may or may not be running depending on exactly where execution was). The safety risk is low because app_func_stim_off() is designed for unconditional shutdown, but the exact hardware state at the moment of interrupt has not been characterized under this race condition. A future hardening measure would be to set a therapy_init_complete flag only after app_mode_therapy_start() returns successfully, and to re-check the WPT state at the top of the therapy handler's while loop before arming the scheduled-therapy logic.