Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions firmware/sdkconfig.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 109 additions & 16 deletions firmware/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> = 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;
Expand All @@ -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);
}

Expand All @@ -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);
Expand All @@ -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");
}
}

Expand All @@ -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));
}
}
}
}
Expand Down Expand Up @@ -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);
}
}
}
9 changes: 8 additions & 1 deletion firmware/src/ble_hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions firmware/src/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
21 changes: 20 additions & 1 deletion firmware/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand All @@ -43,6 +44,7 @@ mod fingerprint;
mod fonts;
mod rtc;


fn main() -> anyhow::Result<()> {
link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
Expand Down Expand Up @@ -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);
}

// ------------------------------------------------------------------
Expand All @@ -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,
Expand Down
Loading