diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index afdd3bc..6c1a7dd 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -18,7 +18,29 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 CONFIG_PM_ENABLE=y CONFIG_FREERTOS_USE_TICKLESS_IDLE=y CONFIG_PM_DFS_INIT_AUTO=y -# Allow NimBLE to maintain the BLE link during light sleep using the 32 kHz -# sleep clock. Without this, any active BLE connection blocks light sleep -# entirely, making CONFIG_PM_ENABLE effectively a no-op while connected. + +# RUN_BLE_ON_SUSPEND is required: without it the BT controller holds a +# NO_LIGHT_SLEEP lock permanently, making light sleep impossible regardless of +# unicore mode. CONFIG_BT_NIMBLE_RUN_BLE_ON_SUSPEND=y + +# BT modem sleep reduces BLE radio power between events (~20-25 mA saving). +# ORIG mode (mode 1) is the only mode available on ESP32. +# SLEEP_CLOCK_USE_MAIN_XTAL avoids the 32 kHz crystal requirement: the +# ESP32-PICO on M5StickC Plus 2 has no external 32 kHz XTAL wired to the BT +# controller, so without this flag the BTDM controller cannot find its sleep +# clock and enters a crash/reboot loop. The main 40 MHz XTAL divided internally +# is slightly less accurate but fully functional. +CONFIG_BT_CTRL_MODEM_SLEEP=y +CONFIG_BT_CTRL_MODEM_SLEEP_MODE_1=y +CONFIG_BT_CTRL_SLEEP_CLOCK_USE_MAIN_XTAL=y + +# PM profiling: enables esp_pm_dump_locks() which logs every active PM lock. +CONFIG_PM_PROFILING=y + +# Single-core mode: required for automatic light sleep on ESP32. +# ESP-IDF explicitly documents that tickless idle light sleep is only supported +# in single-core mode; in dual-core mode both cores must be simultaneously idle, +# which never happens with BLE and UART driver tasks running. +# Also powers off Core 1 entirely, saving ~10-15 mA. +CONFIG_FREERTOS_UNICORE=y diff --git a/firmware/src/app.rs b/firmware/src/app.rs index 50cc1d0..b4b5383 100644 --- a/firmware/src/app.rs +++ b/firmware/src/app.rs @@ -185,7 +185,12 @@ where let mut last_minute: u8 = 255; // force topbar draw on first iteration let mut tick: u32 = 0; - const SCREEN_TIMEOUT_TICKS: u32 = 1_500; // 30 s at POLL_MS/tick + // Standby diagnostics — log battery drain rate + PM locks every 60 s while screen is off. + let mut last_diag_us: i64 = 0; + let mut diag_bat_at_screen_off: Option = None; + let mut diag_ts_at_screen_off: u64 = 0; + + const SCREEN_TIMEOUT_TICKS: u32 = 750; // 15 s at POLL_MS/tick const IDLE_POLL_MS: u32 = 100; // sleep interval when screen is off let mut inactivity_ticks: u32 = 0; let mut screen_on = true; @@ -207,8 +212,11 @@ where ble_hid::start_background_sync(); display::show_status(disp, &sb_boot, "Press B to pair"); } else { - // First boot or no bonds: stay open until first connection. + // First boot or no bonds: open pairing window for 120 s to limit BLE advertising duration. + // The user can reopen it at any time with Button B. ble_hid::open_pairing_window(passkey); + pairing_auto_close_at = + unsafe { esp_idf_svc::sys::time(std::ptr::null_mut()) } as u64 + 120; display::show_pin(disp, &sb_boot, passkey, 0); } @@ -234,12 +242,14 @@ where battery, }; - // Refresh the top bar when the minute changes (no content repaint). + // Refresh the top bar when the minute changes (only while the screen is on). let cur_minute = (unsafe { esp_idf_svc::sys::time(std::ptr::null_mut()) } as u64 / 60 % 60) as u8; if cur_minute != last_minute { last_minute = cur_minute; - display::update_topbar(disp, &sb); + if screen_on { + display::update_topbar(disp, &sb); + } } let connected = ble_hid::CONNECTED.load(Ordering::Relaxed); @@ -248,18 +258,21 @@ where last_connected = connected; pending_bond_clear = false; - if pairing_open { - if ble_hid::PAIRING_ALLOWED.load(Ordering::Relaxed) { - display::show_pin(disp, &sb, passkey, connected); + // Only redraw while screen is on — the display controller is in sleep mode otherwise. + if screen_on { + if pairing_open { + if ble_hid::PAIRING_ALLOWED.load(Ordering::Relaxed) { + display::show_pin(disp, &sb, passkey, connected); + } else if connected > 0 { + display::show_status(disp, &sb, &format!("ACTIVE CLIENTS: {}", connected)); + } else { + display::show_status(disp, &sb, "Press B to pair"); + } } else if connected > 0 { display::show_status(disp, &sb, &format!("ACTIVE CLIENTS: {}", connected)); } else { display::show_status(disp, &sb, "Press B to pair"); } - } else if connected > 0 { - display::show_status(disp, &sb, &format!("ACTIVE CLIENTS: {}", connected)); - } else { - display::show_status(disp, &sb, "Press B to pair"); } } @@ -270,10 +283,12 @@ where ble_hid::close_pairing_window(); pairing_open = false; pairing_auto_close_at = 0; - if connected == 0 { - display::show_status(disp, &sb, "Press B to pair"); - } else { - display::show_status(disp, &sb, &format!("ACTIVE CLIENTS: {}", connected)); + if screen_on { + if connected == 0 { + display::show_status(disp, &sb, "Press B to pair"); + } else { + display::show_status(disp, &sb, &format!("ACTIVE CLIENTS: {}", connected)); + } } } } @@ -532,8 +547,86 @@ where backlight.set_low().ok(); screen_on = false; fp.standby(); + // Capture the battery level and timestamp at the moment of screen-off so we can + // compute a cumulative average-mA figure later. + battery = read_battery(); + last_battery_tick = tick; + diag_bat_at_screen_off = battery; + diag_ts_at_screen_off = + unsafe { esp_idf_svc::sys::time(std::ptr::null_mut()) } as u64; + last_diag_us = unsafe { esp_idf_svc::sys::esp_timer_get_time() }; + log::info!( + "[DIAG] screen off — bat={:?}% connected={} pairing={}", + battery, + connected, + pairing_open + ); } - FreeRtos::delay_ms(if screen_on { POLL_MS } else { IDLE_POLL_MS }); + // Standby diagnostics: every 60 s while screen is off, log the average current drain + // (computed from battery % drop × 200 mAh) and dump all active PM locks so we can + // identify what is preventing light sleep. Requires CONFIG_PM_PROFILING=y. + if !screen_on { + let now_us = unsafe { esp_idf_svc::sys::esp_timer_get_time() }; + if now_us - last_diag_us >= 60_000_000 { + last_diag_us = now_us; + battery = read_battery(); + last_battery_tick = tick; + let uptime_s = now_us / 1_000_000; + let now_ts = + unsafe { esp_idf_svc::sys::time(std::ptr::null_mut()) } as u64; + match (diag_bat_at_screen_off, battery) { + (Some(bat_off), Some(bat_now)) + if diag_ts_at_screen_off > 0 && now_ts > diag_ts_at_screen_off => + { + let elapsed_s = now_ts - diag_ts_at_screen_off; + let drop = bat_off.saturating_sub(bat_now) as u64; + // mA = (200 mAh × drop/100) / (elapsed_s/3600) + // = 7200 × drop / elapsed_s + // Multiply by 10 for one decimal place of precision. + let ma_x10 = if elapsed_s > 0 { + 72_000 * drop / elapsed_s + } else { + 0 + }; + log::info!( + "[DIAG] standby t={}s bat={}% (was {}% {}s ago → {}.{}mA) \ + connected={} pairing={}", + uptime_s, + bat_now, + bat_off, + elapsed_s, + ma_x10 / 10, + ma_x10 % 10, + connected, + pairing_open + ); + } + _ => { + log::info!( + "[DIAG] standby t={}s bat={:?} connected={} pairing={}", + uptime_s, + battery, + connected, + pairing_open + ); + } + } + // PM lock tracing is enabled at VERBOSE level (see main.rs). + // To see which lock blocks light sleep: + // cargo run --release 2>&1 | grep NO_LIGHT_SLEEP + // An "acquire NO_LIGHT_SLEEP" with no matching "release" is the culprit. + log::info!( + "[DIAG] heap_free_min={}B", + unsafe { esp_idf_svc::sys::esp_get_minimum_free_heap_size() } + ); + } + } + + if screen_on { + FreeRtos::delay_ms(POLL_MS); + } else { + FreeRtos::delay_ms(IDLE_POLL_MS); + } } } diff --git a/firmware/src/ble_hid.rs b/firmware/src/ble_hid.rs index dfabd35..6542c25 100644 --- a/firmware/src/ble_hid.rs +++ b/firmware/src/ble_hid.rs @@ -147,13 +147,20 @@ pub fn init(passkey: u32) -> BleHid { // NOTE: do NOT reset SUBSCRIBED in on_connect. On reconnection with a stored // bond, macOS writes the CCCD (re-enabling notifications) *before* the GAP // connect event fires — resetting here would erase that subscription. - server.on_connect(|_, desc| { + server.on_connect(|server, desc| { let count = CONNECTED.fetch_add(1, Ordering::Relaxed) + 1; log::info!( "[BLE] link connected: {:?} (total: {})", desc.address(), count ); + // Request a longer connection interval to reduce BLE radio wakeup frequency. + // min=100 ms (80×1.25 ms), max=200 ms (160×1.25 ms), latency=4 (skip up to 4 events + // when idle, giving an effective max wakeup period of 200×5 = 1000 ms), supervision + // timeout=4 s. The host OS may negotiate different values but this signals intent. + if let Err(e) = server.update_conn_params(desc.conn_handle(), 80, 160, 4, 400) { + log::warn!("[BLE] conn param update failed: {:?}", e); + } // If the pairing window is still open and we have slots, keep/restart advertising. if PAIRING_ALLOWED.load(Ordering::Relaxed) && count < 3 { let _ = BLEDevice::take().get_advertising().lock().start(); diff --git a/firmware/src/fingerprint.rs b/firmware/src/fingerprint.rs index 3d1f9c4..f052238 100644 --- a/firmware/src/fingerprint.rs +++ b/firmware/src/fingerprint.rs @@ -492,6 +492,8 @@ impl<'d> FingerprintSensor<'d> { self.smart_poll_until = None; self.driver.drain_rx(); + // Turn off the LED ring before sleeping to avoid unnecessary current draw. + let _ = self.driver.set_led(LedMode::Off, LedColor::Off, 0); let _ = self.driver.set_work_mode(0); // 0 = Timed Sleep let _ = self.driver.set_sleep_time(10); } diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 5a3e089..dd5c19c 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -23,7 +23,8 @@ use esp_idf_svc::{ units::Hertz, }, sys::{ - esp_pm_config_esp32_t, esp_pm_configure, esp_sleep_enable_uart_wakeup, link_patches, + esp_log_level_set, esp_log_level_t_ESP_LOG_VERBOSE, esp_pm_config_esp32_t, + esp_pm_configure, esp_sleep_enable_uart_wakeup, link_patches, uart_port_t_UART_NUM_0, uart_port_t_UART_NUM_1, uart_set_wakeup_threshold, }, }; @@ -43,6 +44,7 @@ mod fingerprint; mod fonts; mod rtc; + fn main() -> anyhow::Result<()> { link_patches(); esp_idf_svc::log::EspLogger::initialize_default(); @@ -227,8 +229,18 @@ fn main() -> anyhow::Result<()> { FreeRtos::delay_ms(2000); unsafe { + // UART1 (Grove / fingerprint sensor): wakeup on autonomous wakeup packet. uart_set_wakeup_threshold(uart_port_t_UART_NUM_1, 3); esp_sleep_enable_uart_wakeup(uart_port_t_UART_NUM_1 as i32); + + // UART0 (USB-C / CLI): the driver holds an ESP_PM_APB_FREQ_MAX lock while + // installed, which normally blocks light sleep entirely. Registering UART0 + // as a wakeup source releases that lock so the PM driver can enter light + // sleep between CLI commands. The first byte of an incoming command may be + // partially lost (start bit + up to 2 data bits), but the JSON framing means + // the command will simply be rejected and the user can resend it. + uart_set_wakeup_threshold(uart_port_t_UART_NUM_0, 3); + esp_sleep_enable_uart_wakeup(uart_port_t_UART_NUM_0 as i32); } // ------------------------------------------------------------------ @@ -242,6 +254,13 @@ fn main() -> anyhow::Result<()> { PinDriver::input(peripherals.pins.gpio35)?, ); + // Enable verbose PM lock tracing so every acquire/release appears in the serial log. + // Filter with: espflash flash --monitor 2>&1 | grep "NO_LIGHT_SLEEP" + // A lock that is acquired but never released is what blocks light sleep. + unsafe { + esp_log_level_set(b"pm\0".as_ptr() as *const core::ffi::c_char, esp_log_level_t_ESP_LOG_VERBOSE); + } + app::run( &ble, &mut disp,