What changed, and why it matters
This commit adds battery safety monitoring to Trezor hardware wallets. It detects problems like a disconnected temperature sensor (NTC), charging that is stuck at a low current, and sudden jumps in battery temperature or voltage. When such a problem is detected during production testing, the device shows a red error screen and disables charging. The change is defensive: it is meant to prevent unsafe charging conditions rather than introduce a security flaw.
Treat as a defensive safety feature, not a vulnerability. Review the new pm_charging_enable/disable syscalls for correct gating and verify that the production-test error screen cannot be bypassed in a way that leaves charging disabled/enabled in an unsafe state. Also fix the inconsistent MicroPython constant name EVENT_BATTERY_TEMP_JUMP_UPDATED to match EVENT_BATTERY_TEMP_JUMP_DETECTED.
Security signals we found
New battery safety error detection and charging-disable path added
New syscalls for enabling/disabling charging exposed to userspace
Verified syscall dispatch used for new charging control syscalls
Production-test UI now blocks on battery error states and disables charging
Potential naming mismatch in MicroPython event constant: EVENT_BATTERY_TEMP_JUMP_UPDATED vs EVENT_BATTERY_TEMP_JUMP_DETECTED
Evidence from the diff
The patch extends the power-manager subsystem to expose new state flags (ntc_connected, charging_limited, temp_control_active, battery_ocv, battery_temp) and new events (ntc_connected_changed, charging_limited_changed, battery_temp_jump_detected, battery_ocv_jump_detected). The PMIC driver now reports NTC/battery disconnect and charging-phase flags. A jump-detection filter using an exponential moving average flags temperature swings >5 °C or OCV swings >500 mV within 5 seconds. A 5-second constant-current low-current filter detects charging-limited conditions. Rust UI code in the Eckhart production-test welcome screen reacts to these events by disabling charging and rendering a red error overlay with an exit button. New syscalls pm_charging_enable/disable are added and wired through the verified syscall dispatch. A minor inconsistency exists in the MicroPython constant name: EVENT_BATTERY_TEMP_JUMP_UPDATED vs EVENT_BATTERY_TEMP_JUMP_DETECTED elsewhere.
Changed components
core/embed/sys/power_managercore/embed/sys/power_manager/stm32u5/power_manager.ccore/embed/sys/power_manager/power_manager_poll.ccore/embed/sys/power_manager/npm1300/npm1300.ccore/embed/sys/syscall/stm32/syscall_dispatch.ccore/embed/sys/syscall/stm32/syscall_stubs.ccore/embed/rust/src/trezorhal/power_manager.rscore/embed/rust/src/ui/layout_eckhart/prodtest/welcome.rscore/embed/upymod/modtrezorio/modtrezorio-pm.hInspect captured patch +410 / −34
diff --git a/core/embed/rust/build.rs b/core/embed/rust/build.rs
index 16be040d7..d4ffd477d 100644
--- a/core/embed/rust/build.rs
+++ b/core/embed/rust/build.rs
@@ -482,6 +482,8 @@ fn generate_trezorhal_bindings() {
.allowlist_function("pm_get_state")
.allowlist_function("pm_suspend")
.allowlist_function("pm_hibernate")
+ .allowlist_function("pm_charging_enable")
+ .allowlist_function("pm_charging_disable")
// irq
.allowlist_function("irq_lock_fn")
.allowlist_function("irq_unlock_fn")
diff --git a/core/embed/rust/src/trezorhal/power_manager.rs b/core/embed/rust/src/trezorhal/power_manager.rs
index 5a2aff3d5..19e88bf11 100644
--- a/core/embed/rust/src/trezorhal/power_manager.rs
+++ b/core/embed/rust/src/trezorhal/power_manager.rs
@@ -18,7 +18,11 @@ pub fn pm_parse_event(event: ffi::pm_event_t) -> PMEvent {
unsafe {
pm_event.usb_connected_changed = event.flags.usb_connected_changed();
pm_event.wireless_connected_changed = event.flags.wireless_connected_changed();
+ pm_event.ntc_connected_changed = event.flags.ntc_connected_changed();
+ pm_event.charging_limited_changed = event.flags.charging_limited_changed();
pm_event.soc_updated = event.flags.soc_updated();
+ pm_event.battery_temp_jump_detected = event.flags.battery_temp_jump_detected();
+ pm_event.battery_ocv_jump_detected = event.flags.battery_ocv_jump_detected();
pm_event.charging_status_changed = event.flags.charging_status_changed();
pm_event.power_status_changed = event.flags.power_status_changed();
}
@@ -48,6 +52,18 @@ pub fn is_usb_connected() -> bool {
state.usb_connected
}
+pub fn is_ntc_connected() -> bool {
+ let mut state: ffi::pm_state_t = unsafe { core::mem::zeroed() };
+ unsafe { ffi::pm_get_state(&mut state as _) };
+ state.ntc_connected
+}
+
+pub fn is_charging_limited() -> bool {
+ let mut state: ffi::pm_state_t = unsafe { core::mem::zeroed() };
+ unsafe { ffi::pm_get_state(&mut state as _) };
+ state.charging_limited
+}
+
pub fn suspend() {
unsafe { ffi::pm_suspend(null_mut()) };
}
@@ -55,3 +71,10 @@ pub fn suspend() {
pub fn hibernate() {
unsafe { ffi::pm_hibernate() };
}
+
+pub fn charging_enable() {
+ unsafe { ffi::pm_charging_enable() };
+}
+pub fn charging_disable() {
+ unsafe { ffi::pm_charging_disable() };
+}
diff --git a/core/embed/rust/src/ui/event/power_manager.rs b/core/embed/rust/src/ui/event/power_manager.rs
index f6c6a336d..7ecd77b3e 100644
--- a/core/embed/rust/src/ui/event/power_manager.rs
+++ b/core/embed/rust/src/ui/event/power_manager.rs
@@ -5,6 +5,10 @@ pub struct PMEvent {
pub charging_status_changed: bool,
pub usb_connected_changed: bool,
pub wireless_connected_changed: bool,
+ pub ntc_connected_changed: bool,
+ pub charging_limited_changed: bool,
+ pub battery_temp_jump_detected: bool,
+ pub battery_ocv_jump_detected: bool,
pub soc_updated: bool,
}
@@ -15,7 +19,11 @@ impl PMEvent {
charging_status_changed: (flags & (1 << 1)) != 0,
usb_connected_changed: (flags & (1 << 2)) != 0,
wireless_connected_changed: (flags & (1 << 3)) != 0,
- soc_updated: (flags & (1 << 4)) != 0,
+ ntc_connected_changed: (flags & (1 << 4)) != 0,
+ charging_limited_changed: (flags & (1 << 5)) != 0,
+ battery_temp_jump_detected: (flags & (1 << 6)) != 0,
+ battery_ocv_jump_detected: (flags & (1 << 7)) != 0,
+ soc_updated: (flags & (1 << 8)) != 0,
}
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/prodtest/welcome.rs b/core/embed/rust/src/ui/layout_eckhart/prodtest/welcome.rs
index 9ca53de1a..350bd3f98 100644
--- a/core/embed/rust/src/ui/layout_eckhart/prodtest/welcome.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/prodtest/welcome.rs
@@ -1,12 +1,16 @@
use super::super::{
+ component::{Button, ButtonMsg},
constant::SCREEN,
cshape::ScreenBorder,
fonts,
- theme::{GREEN, RED, WHITE},
+ theme::{bootloader::button_cancel, BLUE, GREEN, RED, WHITE},
};
use crate::{
strutil::format_i64,
- trezorhal::power_manager::{charging_state, soc, ChargingState},
+ trezorhal::power_manager::{
+ charging_disable, charging_enable, charging_state, is_charging_limited, is_ntc_connected,
+ soc, ChargingState,
+ },
ui::{
component::{Component, Event, EventCtx, Never, Qr},
constant::screen,
@@ -21,6 +25,12 @@ pub struct Welcome {
id_text_pos: Option<Point>,
qr: Option<Qr>,
screen_border: ScreenBorder,
+ // Error state triggered by NTC connected change entering true
+ error_active: bool,
+ // Headline of the error, based on the triggering condition
+ error_headline: &'static str,
+ // Prepared Button component to exit error state
+ exit_btn: Button,
}
impl Welcome {
@@ -33,19 +43,47 @@ impl Welcome {
None
};
+ // Determine initial error state and headline
+ let (error_active, error_headline) = Self::detect_error();
+
Self {
id,
id_text_pos: None,
qr,
screen_border: ScreenBorder::new(Color::white()),
+ // Enter error state based on startup conditions
+ error_active,
+ error_headline,
+ // Use prepared Button component
+ exit_btn: Button::with_text("Exit".into()).styled(button_cancel()),
}
}
+
+ fn detect_error() -> (bool, &'static str) {
+ let (error_active, error_headline) = if !is_ntc_connected() {
+ (true, "NTC Error")
+ } else if is_charging_limited() {
+ (true, "Charging Limited")
+ } else {
+ (false, "Error")
+ };
+ (error_active, error_headline)
+ }
}
impl Component for Welcome {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
+ // Compute button rect for error state and place the prepared Button component
+ let btn_width = screen().width() * 3 / 5; // 60% width
+ let btn_height = 84; // px
+ let btn_area = Rect::from_center_and_size(
+ screen().bottom_center() - Offset::y(80),
+ Offset::new(btn_width, btn_height),
+ );
+ self.exit_btn.place(btn_area);
+
if self.id.is_some() {
// place the qr in the middle of the screen and size it to half the screen
let qr_width = screen().width() / 2;
@@ -62,24 +100,107 @@ impl Component for Welcome {
}
fn event(&mut self, ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
- if let Event::PM(e) = _event {
- if e.soc_updated {
- ctx.request_paint();
+ match _event {
+ Event::Attach(_) => {
+ if self.error_active {
+ ctx.request_paint();
+ }
+ None
+ }
+ Event::PM(e) => {
+ if e.soc_updated || e.charging_status_changed {
+ ctx.request_paint();
+ }
+
+ if !self.error_active {
+ // Error case: NTC connected changed and is now false
+ if e.ntc_connected_changed && !is_ntc_connected() {
+ self.error_active = true;
+ self.error_headline = "NTC Error";
+ charging_disable();
+ ctx.request_paint();
+ }
+ // Error case: charging limited changed and is now true
+ if e.charging_limited_changed && is_charging_limited() {
+ self.error_active = true;
+ self.error_headline = "Charging Limited";
+ charging_disable();
+ ctx.request_paint();
+ }
+ // Error case: Battery temperature jump detected event
+ if e.battery_temp_jump_detected {
+ self.error_active = true;
+ self.error_headline = "Battery Temp Jump";
+ charging_disable();
+ ctx.request_paint();
+ }
+ // Error case: Battery OCV jump detected event
+ if e.battery_ocv_jump_detected {
+ self.error_active = true;
+ charging_disable();
+ self.error_headline = "Battery OCV Jump";
+ ctx.request_paint();
+ }
+ }
+ None
}
- if e.charging_status_changed {
- ctx.request_paint();
+ Event::Touch(t) => {
+ // In error mode, delegate touch events to the prepared Button
+ if self.error_active {
+ if let Some(ButtonMsg::Clicked) = self.exit_btn.event(ctx, Event::Touch(t)) {
+ let (error_active, error_headline) = Self::detect_error();
+
+ if !error_active {
+ self.error_active = false;
+ charging_enable();
+ } else {
+ self.error_active = true;
+ self.error_headline = error_headline;
+ }
+ ctx.request_paint();
+ }
+ }
+ None
}
+ _ => None,
}
-
- None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ if self.error_active {
+ // Error screen rendering
+ shape::Bar::new(SCREEN).with_bg(RED).render(target);
+ self.screen_border.render(u8::MAX, target);
+
+ // Error message
+ shape::Text::new(
+ screen().center() - Offset::y(20),
+ self.error_headline,
+ fonts::FONT_SATOSHI_REGULAR_38,
+ )
+ .with_fg(WHITE)
+ .with_align(Alignment::Center)
+ .render(target);
+
+ shape::Text::new(
+ screen().center() + Offset::y(12),
+ "Tap to exit error",
+ fonts::FONT_SATOSHI_REGULAR_22,
+ )
+ .with_fg(WHITE)
+ .with_align(Alignment::Center)
+ .render(target);
+
+ // Render the prepared Button component
+ self.exit_btn.render(target);
+ return;
+ }
+
let state = charging_state();
let (state_text, bg_color) = match state {
ChargingState::Charging => ("Charging", Color::black()),
- ChargingState::Discharging => ("Discharging", RED),
+ ChargingState::Discharging => ("Discharging", BLUE),
ChargingState::Idle => ("Idle", GREEN),
};
diff --git a/core/embed/sys/power_manager/fuel_gauge/fuel_gauge.h b/core/embed/sys/power_manager/fuel_gauge/fuel_gauge.h
index 88d8e872c..2e8b1a897 100644
--- a/core/embed/sys/power_manager/fuel_gauge/fuel_gauge.h
+++ b/core/embed/sys/power_manager/fuel_gauge/fuel_gauge.h
@@ -23,25 +23,31 @@
#include "battery_model.h"
-// fuel gauge state structure
+/**
+ * @brief Fuel gauge state structure
+ */
typedef struct {
- battery_model_t model;
+ battery_model_t model; ///< Battery model parameters
- // State estimate (SOC)
- float soc;
- // Latched SOC (the one that gets reported)
- float soc_latched;
- // Error covariance
- float P;
- // Filter parameters
- float R; // Measurement noise variance
- float Q; // Process noise variance
- float R_aggressive; // Aggressive measurement noise variance
- float Q_aggressive; // Aggressive process noise variance
+ /** @name State estimates */
+ /**@{*/
+ float soc; ///< State of charge estimate (0.0 to 1.0)
+ float soc_latched; ///< Latched SOC (the one that gets reported)
+ float P; ///< Error covariance
+ /**@}*/
+
+ /** @name Filter parameters */
+ /**@{*/
+ float R; ///< Measurement noise variance
+ float Q; ///< Process noise variance
+ float R_aggressive; ///< Aggressive measurement noise variance
+ float Q_aggressive; ///< Aggressive process noise variance
+ /**@}*/
} fuel_gauge_state_t;
/**
- * Initialize the fuel gauge state
+ * @brief Initialize the fuel gauge state
+ *
* @param state Pointer to EKF state structure
* @param R Measurement noise variance
* @param Q Process noise variance
@@ -53,20 +59,23 @@ void fuel_gauge_init(fuel_gauge_state_t* state, float R, float Q,
float R_aggressive, float Q_aggressive, float P_init);
/**
- * Reset the EKF state
+ * @brief Reset the EKF state
+ *
* @param state Pointer to EKF state structure
*/
void fuel_gauge_reset(fuel_gauge_state_t* state);
/**
- * Set SOC directly
+ * @brief Set SOC directly
+ *
* @param state Pointer to EKF state structure
* @param soc State of charge (0.0 to 1.0)
*/
void fuel_gauge_set_soc(fuel_gauge_state_t* state, float soc, float P);
/**
- * Make initial SOC guess based on OCV
+ * @brief Make initial SOC guess based on OCV
+ *
* @param state Pointer to EKF state structure
* @param voltage_V Current battery voltage (V)
* @param current_mA Current battery current (mA), positive for discharge
@@ -76,9 +85,10 @@ void fuel_gauge_initial_guess(fuel_gauge_state_t* state, float voltage_V,
float current_mA, float temperature);
/**
- * Update the fuel gauge with new measurements
+ * @brief Update the fuel gauge with new measurements
+ *
* @param state Pointer to EKF state structure
- * @param dt Time step in milliseconds
+ * @param dt_ms Time step in milliseconds
* @param voltage_V Current battery voltage (V)
* @param current_mA Current battery current (mA), positive for discharge
* @param temperature Battery temperature (°C)
diff --git a/core/embed/sys/power_manager/inc/sys/pmic.h b/core/embed/sys/power_manager/inc/sys/pmic.h
index d2b9905b2..392cd7acb 100644
--- a/core/embed/sys/power_manager/inc/sys/pmic.h
+++ b/core/embed/sys/power_manager/inc/sys/pmic.h
@@ -44,13 +44,22 @@ typedef struct {
// IBAT_MEAS_STATUS register value
// (for debugging purposes, see the datasheet)
uint8_t ibat_meas_status;
- // BUCKSTATUS register value
+ // BCHGCHARGESTATUS register value
// (for debugging purposes, see the datasheet)
uint8_t charge_status;
uint8_t charge_err;
uint8_t charge_sensor_err;
uint8_t buck_status;
uint8_t usb_status;
+ // NTC disconnection flag
+ bool ntc_disconnected;
+ // battery disconnected flag
+ bool battery_disconnected;
+ // Charging phase flags decoded from charge_status
+ // - cc_phase: Constant-Current phase (charge_status bit 3)
+ // - cv_phase: Constant-Voltage phase (charge_status bit 5)
+ bool cc_phase;
+ bool cv_phase;
} pmic_report_t;
typedef void (*pmic_report_callback_t)(void* context, pmic_report_t* report);
diff --git a/core/embed/sys/power_manager/inc/sys/power_manager.h b/core/embed/sys/power_manager/inc/sys/power_manager.h
index c786b4795..6d79c7f87 100644
--- a/core/embed/sys/power_manager/inc/sys/power_manager.h
+++ b/core/embed/sys/power_manager/inc/sys/power_manager.h
@@ -57,7 +57,13 @@ typedef union {
bool charging_status_changed : 1;
bool usb_connected_changed : 1;
bool wireless_connected_changed : 1;
+ bool ntc_connected_changed : 1;
+ bool charging_limited_changed : 1;
+ bool temp_control_active_changed : 1;
+ // Jump detection events (fast changes within a short time window)
+ bool battery_temp_jump_detected : 1;
+ bool battery_ocv_jump_detected : 1;
bool soc_updated : 1;
} flags;
} pm_event_t;
@@ -66,9 +72,17 @@ typedef union {
typedef struct {
bool usb_connected;
bool wireless_connected;
+ bool ntc_connected;
+ bool charging_limited;
+ bool temp_control_active;
pm_charging_status_t charging_status;
pm_power_status_t power_status;
uint8_t soc;
+
+ // used for detection of unexpected changes
+ float battery_ocv;
+ float battery_temp;
+
} pm_state_t;
/* Power system report */
diff --git a/core/embed/sys/power_manager/npm1300/npm1300.c b/core/embed/sys/power_manager/npm1300/npm1300.c
index 5e8c62f86..b319aa087 100644
--- a/core/embed/sys/power_manager/npm1300/npm1300.c
+++ b/core/embed/sys/power_manager/npm1300/npm1300.c
@@ -45,6 +45,15 @@
// Delay inserted between the ADC trigger and the readout [ms]
#define NPM1300_ADC_READOUT_DELAY 80
+// Minimum temperature that counts as valid data
+#define NPM1300_NTC_TEMP_VALID_MIN (-80.0)
+
+// Minimum temperature that counts as valid data
+#define NPM1300_NTC_TEMP_VALID_MAX (100.0)
+
+// Minimum battery voltage that counts as valid data
+#define NPM1300_BATT_VOLTAGE_VALID_MIN (0.5)
+
// NPM1300 FSM states
typedef enum {
NPM1300_STATE_IDLE = 0,
@@ -687,6 +696,11 @@ static void npm1300_calculate_report(npm1300_driver_t* drv,
// VBAT is scaled by the voltage divider ratio and ADC resolution.
report->vbat = (vbat_adc * 5.0) / 1023.0;
+ // if the battery voltage is below the accepted minimum, flag the battery as
+ // disconnected
+ report->battery_disconnected =
+ (report->vbat < NPM1300_BATT_VOLTAGE_VALID_MIN);
+
// Calculate the temperature from the NTC (thermistor).
// Beta value for the thermistor is specified as 3380.
// The equation is derived from the NPM1300 datasheet.
@@ -695,6 +709,11 @@ static void npm1300_calculate_report(npm1300_driver_t* drv,
1 / (1 / 298.15 - (1 / beta) * logf(1024.0 / ntc_adc - 1)) - 298.15 +
25.0;
+ // if the temperature is below the accepted minimum, flag the NTC as
+ // disconnected
+ report->ntc_disconnected = (report->ntc_temp < NPM1300_NTC_TEMP_VALID_MIN ||
+ report->ntc_temp > NPM1300_NTC_TEMP_VALID_MAX);
+
// Calculate the die temperature from the die ADC reading.
// The equation is derived from the NPM1300 datasheet.
report->die_temp = 394.67 - 0.7926 * die_adc;
@@ -709,6 +728,10 @@ static void npm1300_calculate_report(npm1300_driver_t* drv,
report->buck_status = r->buck_status;
report->usb_status = r->usb_status;
report->charge_status = r->charging_status;
+ // Decode and expose charging phase flags
+ // Bit 3 -> Constant-Current phase, Bit 4 -> Constant-Voltage phase
+ report->cc_phase = (r->charging_status & 0x08) != 0;
+ report->cv_phase = (r->charging_status & 0x10) != 0;
report->charge_err = r->charging_err;
report->charge_sensor_err = r->charging_sensor_err;
}
diff --git a/core/embed/sys/power_manager/power_manager_poll.c b/core/embed/sys/power_manager/power_manager_poll.c
index c870a3def..e30151f06 100644
--- a/core/embed/sys/power_manager/power_manager_poll.c
+++ b/core/embed/sys/power_manager/power_manager_poll.c
@@ -23,15 +23,27 @@
#include <sys/power_manager.h>
#include <sys/sysevent_source.h>
+#include <sys/systick.h>
#include "power_manager_poll.h"
+typedef struct {
+ float filtered;
+ uint32_t t_last_ms;
+} pm_jump_detector_t;
+
typedef struct {
// Last state
pm_state_t last_state;
// Pending events
pm_event_t events;
+ // Jump detection state for battery temperature
+ pm_jump_detector_t temp_detector;
+
+ // Jump detection state for battery open-circuit voltage (OCV)
+ pm_jump_detector_t ocv_detector;
+
} pm_fsm_t;
// State machine for each task
@@ -58,12 +70,67 @@ bool pm_get_events(pm_event_t* events) {
return false;
}
+static bool pm_detect_jump(pm_jump_detector_t* detector, float value,
+ float threshold, uint32_t tau_ms) {
+ const uint32_t now = systick_ms();
+
+ if (detector->t_last_ms == 0U) {
+ detector->filtered = value;
+ detector->t_last_ms = now;
+ return false;
+ }
+
+ const uint32_t dt_ms = now - detector->t_last_ms;
+ detector->t_last_ms = now;
+
+ if (dt_ms == 0U) {
+ return false;
+ }
+
+ // Use Exponential Moving Average (EMA) to detect jumps.
+ // The EMA provides a smooth baseline that follows the signal with a lag.
+ // If the difference between the current value and the baseline exceeds
+ // the threshold, we consider it a jump.
+ // alpha = dt / (tau + dt)
+ const float alpha = (float)dt_ms / (float)(tau_ms + dt_ms);
+
+ const float diff = value - detector->filtered;
+ float abs_diff = (diff < 0.0f) ? -diff : diff;
+
+ if (abs_diff >= threshold) {
+ // Jump detected! Reset filter to current value to avoid multiple triggers
+ detector->filtered = value;
+ return true;
+ }
+
+ // Update filtered value (low-pass filter)
+ detector->filtered += alpha * diff;
+
+ return false;
+}
+
static bool pm_fsm_update(pm_fsm_t* fsm, pm_state_t* new_state) {
// Return true if there are any state changes
if (new_state->soc != fsm->last_state.soc) {
fsm->events.flags.soc_updated = true;
}
+ // Detect battery temperature jump
+ const float TEMP_JUMP_THRESHOLD_C = 5.0f;
+ const uint32_t TEMP_JUMP_WINDOW_MS = 5000; // 5 seconds
+ if (pm_detect_jump(&fsm->temp_detector, new_state->battery_temp,
+ TEMP_JUMP_THRESHOLD_C, TEMP_JUMP_WINDOW_MS)) {
+ fsm->events.flags.battery_temp_jump_detected = true;
+ }
+
+ // Detect battery OCV jump
+ const float OCV_JUMP_THRESHOLD_V = 0.50f; // 500 mV
+ const uint32_t OCV_JUMP_WINDOW_MS = 5000; // 5 seconds
+ if (pm_detect_jump(&fsm->ocv_detector, new_state->battery_ocv,
+ OCV_JUMP_THRESHOLD_V, OCV_JUMP_WINDOW_MS)) {
+ fsm->events.flags.battery_ocv_jump_detected = true;
+ }
+
if (new_state->usb_connected != fsm->last_state.usb_connected) {
fsm->events.flags.usb_connected_changed = true;
}
@@ -80,6 +147,14 @@ static bool pm_fsm_update(pm_fsm_t* fsm, pm_state_t* new_state) {
fsm->events.flags.charging_status_changed = true;
}
+ if (new_state->ntc_connected != fsm->last_state.ntc_connected) {
+ fsm->events.flags.ntc_connected_changed = true;
+ }
+
+ if (new_state->charging_limited != fsm->last_state.charging_limited) {
+ fsm->events.flags.charging_limited_changed = true;
+ }
+
fsm->last_state = *new_state;
return fsm->events.all != 0;
diff --git a/core/embed/sys/power_manager/stm32u5/power_manager.c b/core/embed/sys/power_manager/stm32u5/power_manager.c
index 64a7a880d..0be0746e8 100644
--- a/core/embed/sys/power_manager/stm32u5/power_manager.c
+++ b/core/embed/sys/power_manager/stm32u5/power_manager.c
@@ -219,6 +219,7 @@ pm_status_t pm_get_state(pm_state_t* state) {
state->usb_connected = drv->usb_connected;
state->wireless_connected = drv->wireless_connected;
+ state->ntc_connected = !drv->pmic_data.ntc_disconnected;
if (pm_is_charging()) {
state->charging_status = PM_BATTERY_CHARGING;
@@ -228,8 +229,46 @@ pm_status_t pm_get_state(pm_state_t* state) {
state->charging_status = PM_BATTERY_IDLE;
}
+ // Charging-limited detection with 5s filter
+ // Conditions to consider:
+ // - Only when charging
+ // - Only when PMIC reports constant-current phase (decoded flag)
+ // - Consider measured current vs target with a small margin
+ // - Assert after predicate holds continuously for >= 5000 ms
+ // - Clear immediately when predicate breaks or not charging
+ const bool is_charging = (state->charging_status == PM_BATTERY_CHARGING);
+ const float MAX_DIFF_MA = 15; // tolerance below target current
+ const uint32_t FILTER_ASSERT_MS = 5000;
+
+ bool predicate = false;
+ if (is_charging) {
+ const bool cc_phase = drv->pmic_data.cc_phase;
+ float iabs_ma = drv->pmic_data.ibat;
+ if (iabs_ma < 0.0f) {
+ iabs_ma = -iabs_ma; // ibat < 0 => charging
+ }
+ predicate = cc_phase && (iabs_ma < (drv->i_chg_target_ma - MAX_DIFF_MA));
+ }
+
+ if (predicate) {
+ uint32_t now = systick_ms();
+ if (drv->charging_limited_start_ms == 0U) {
+ drv->charging_limited_start_ms = now;
+ } else if (!drv->charging_limited_latched &&
+ (now - drv->charging_limited_start_ms) >= FILTER_ASSERT_MS) {
+ drv->charging_limited_latched = true;
+ }
+ } else {
+ drv->charging_limited_start_ms = 0U;
+ drv->charging_limited_latched = false;
+ }
+
+ state->charging_limited = drv->charging_limited_latched;
+
state->power_status = drv->state;
state->soc = drv->soc_ceiled;
+ state->battery_temp = drv->pmic_data.ntc_temp;
+ state->battery_ocv = drv->battery_ocv;
irq_unlock(irq_key);
diff --git a/core/embed/sys/power_manager/stm32u5/power_manager_internal.h b/core/embed/sys/power_manager/stm32u5/power_manager_internal.h
index e079a7c34..c1c343669 100644
--- a/core/embed/sys/power_manager/stm32u5/power_manager_internal.h
+++ b/core/embed/sys/power_manager/stm32u5/power_manager_internal.h
@@ -97,16 +97,25 @@ typedef struct {
uint8_t soc_target;
bool soc_target_reached;
float target_battery_ocv_v_tau;
+ float battery_ocv;
// Battery charging state
bool charging_enabled;
uint16_t i_chg_target_ma;
uint16_t i_chg_max_limit_ma;
+ // Charging-limited detection filter state
+ // - charging_limited_latched: current filtered state exposed to pm_state_t
+ // - charging_limited_start_ms: timestamp when low-current-in-CC predicate
+ // started being true (0 when not timing)
+ bool charging_limited_latched;
+ uint32_t charging_limited_start_ms;
+
#ifdef PM_ENABLE_TEMP_CONTROL
// Temp controller
uint32_t temp_control_timeout;
uint16_t i_chg_temp_limit_ma;
+ bool temp_control_active;
#endif
// Power source hardware state
diff --git a/core/embed/sys/power_manager/stm32u5/power_monitoring.c b/core/embed/sys/power_manager/stm32u5/power_monitoring.c
index 34a92828f..8b3900a1e 100644
--- a/core/embed/sys/power_manager/stm32u5/power_monitoring.c
+++ b/core/embed/sys/power_manager/stm32u5/power_monitoring.c
@@ -91,6 +91,10 @@ void pm_pmic_data_ready(void* context, pmic_report_t* report) {
// Run battery charging controller
pm_charging_controller(drv);
+ drv->battery_ocv =
+ battery_meas_to_ocv(&drv->fuel_gauge.model, drv->pmic_data.vbat,
+ drv->pmic_data.ibat, drv->pmic_data.ntc_temp);
+
if (!drv->fuel_gauge_initialized) {
// Fuel gauge not initialized yet, battery SoC not available, sample the
// battery data into the circular buffer.
@@ -109,7 +113,7 @@ void pm_pmic_data_ready(void* context, pmic_report_t* report) {
PM_SELF_DISG_RATE_SUSPEND_MA, 25.0f);
// TODO: Currently in suspend mode we use single self-discharge rate
- // but in practive the discharge rate may change in case some components
+ // but in practice the discharge rate may change in case some components
// remains active. Since the device is very likely to stay in suspend
// mode for limited time, for now we decided to neglect this. but in
// the future we may want to distinguish between different suspend modes
@@ -135,7 +139,9 @@ void pm_pmic_data_ready(void* context, pmic_report_t* report) {
drv->fully_charged = true;
fuel_gauge_set_soc(&drv->fuel_gauge, 1.0f, drv->fuel_gauge.P);
} else {
- drv->fully_charged = false;
+ if (drv->pmic_data.ibat > 0) {
+ drv->fully_charged = false;
+ }
}
// Ceil the float soc to user-friendly integer
@@ -196,6 +202,10 @@ void pm_charging_controller(pm_driver_t* drv) {
pm_temperature_controller(drv);
#endif
+ if (drv->pmic_data.ntc_disconnected) {
+ drv->i_chg_target_ma = 0;
+ }
+
if (drv->soc_target == 100) {
drv->soc_target_reached = false;
} else if (fabsf((-drv->pmic_data.ibat) - (float)drv->i_chg_target_ma) <=
@@ -276,6 +286,9 @@ static void pm_temperature_controller(pm_driver_t* drv) {
if (drv->i_chg_target_ma > drv->i_chg_temp_limit_ma) {
// Limit the charging current by temperature controller
drv->i_chg_target_ma = drv->i_chg_temp_limit_ma;
+ drv->temp_control_active = true;
+ } else {
+ drv->temp_control_active = false;
}
}
diff --git a/core/embed/sys/syscall/inc/sys/syscall_numbers.h b/core/embed/sys/syscall/inc/sys/syscall_numbers.h
index 4ced88629..d0102d0bc 100644
--- a/core/embed/sys/syscall/inc/sys/syscall_numbers.h
+++ b/core/embed/sys/syscall/inc/sys/syscall_numbers.h
@@ -151,6 +151,8 @@ typedef enum {
SYSCALL_POWER_MANAGER_SUSPEND,
SYSCALL_POWER_MANAGER_HIBERNATE,
+ SYSCALL_POWER_MANAGER_CHARGING_ENABLE,
+ SYSCALL_POWER_MANAGER_CHARGING_DISABLE,
SYSCALL_POWER_MANAGER_GET_STATE,
SYSCALL_POWER_MANAGER_GET_EVENTS,
diff --git a/core/embed/sys/syscall/stm32/syscall_dispatch.c b/core/embed/sys/syscall/stm32/syscall_dispatch.c
index 58a995fc3..c5bfe7210 100644
--- a/core/embed/sys/syscall/stm32/syscall_dispatch.c
+++ b/core/embed/sys/syscall/stm32/syscall_dispatch.c
@@ -757,6 +757,14 @@ __attribute((no_stack_protector)) void syscall_handler(uint32_t *args,
args[0] = pm_hibernate();
} break;
+ case SYSCALL_POWER_MANAGER_CHARGING_ENABLE: {
+ args[0] = pm_charging_enable();
+ } break;
+
+ case SYSCALL_POWER_MANAGER_CHARGING_DISABLE: {
+ args[0] = pm_charging_disable();
+ } break;
+
case SYSCALL_POWER_MANAGER_GET_STATE: {
pm_state_t *status = (pm_state_t *)args[0];
args[0] = pm_get_state__verified(status);
diff --git a/core/embed/sys/syscall/stm32/syscall_stubs.c b/core/embed/sys/syscall/stm32/syscall_stubs.c
index 6f66d5720..6d595304f 100644
--- a/core/embed/sys/syscall/stm32/syscall_stubs.c
+++ b/core/embed/sys/syscall/stm32/syscall_stubs.c
@@ -750,6 +750,14 @@ pm_status_t pm_hibernate(void) {
return (pm_status_t)syscall_invoke0(SYSCALL_POWER_MANAGER_HIBERNATE);
}
+pm_status_t pm_charging_enable(void) {
+ return (pm_status_t)syscall_invoke0(SYSCALL_POWER_MANAGER_CHARGING_ENABLE);
+}
+
+pm_status_t pm_charging_disable(void) {
+ return (pm_status_t)syscall_invoke0(SYSCALL_POWER_MANAGER_CHARGING_DISABLE);
+}
+
pm_status_t pm_get_state(pm_state_t *state) {
return (pm_status_t)syscall_invoke1((uint32_t)state,
SYSCALL_POWER_MANAGER_GET_STATE);
diff --git a/core/embed/upymod/modtrezorio/modtrezorio-pm.h b/core/embed/upymod/modtrezorio/modtrezorio-pm.h
index a05eb657b..b6f6d7b84 100644
--- a/core/embed/upymod/modtrezorio/modtrezorio-pm.h
+++ b/core/embed/upymod/modtrezorio/modtrezorio-pm.h
@@ -34,6 +34,10 @@
/// EVENT_CHARGING_STATUS_CHANGED: int
/// EVENT_USB_CONNECTED_CHANGED: int
/// EVENT_WIRELESS_CONNECTED_CHANGED: int
+/// EVENT_NTC_CONNECTED_CHANGED: int
+/// EVENT_CHARGING_LIMITED_CHANGED: int
+/// EVENT_BATTERY_OCV_JUMP_DETECTED: int
+/// EVENT_BATTERY_TEMP_JUMP_DETECTED: int
/// EVENT_SOC_UPDATED: int
/// def soc() -> int:
@@ -141,7 +145,11 @@ STATIC const mp_rom_map_elem_t mod_trezorio_pm_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR_EVENT_CHARGING_STATUS_CHANGED), MP_ROM_INT(1 << 1)},
{MP_ROM_QSTR(MP_QSTR_EVENT_USB_CONNECTED_CHANGED), MP_ROM_INT(1 << 2)},
{MP_ROM_QSTR(MP_QSTR_EVENT_WIRELESS_CONNECTED_CHANGED), MP_ROM_INT(1 << 3)},
- {MP_ROM_QSTR(MP_QSTR_EVENT_SOC_UPDATED), MP_ROM_INT(1 << 4)},
+ {MP_ROM_QSTR(MP_QSTR_EVENT_NTC_CONNECTED_CHANGED), MP_ROM_INT(1 << 4)},
+ {MP_ROM_QSTR(MP_QSTR_EVENT_CHARGING_LIMITED_CHANGED), MP_ROM_INT(1 << 5)},
+ {MP_ROM_QSTR(MP_QSTR_EVENT_BATTERY_OCV_JUMP_DETECTED), MP_ROM_INT(1 << 6)},
+ {MP_ROM_QSTR(MP_QSTR_EVENT_BATTERY_TEMP_JUMP_UPDATED), MP_ROM_INT(1 << 7)},
+ {MP_ROM_QSTR(MP_QSTR_EVENT_SOC_UPDATED), MP_ROM_INT(1 << 8)},
};
STATIC MP_DEFINE_CONST_DICT(mod_trezorio_pm_globals,
mod_trezorio_pm_globals_table);
diff --git a/core/mocks/generated/trezorio/pm.pyi b/core/mocks/generated/trezorio/pm.pyi
index f5225a41f..22a2b97cd 100644
--- a/core/mocks/generated/trezorio/pm.pyi
+++ b/core/mocks/generated/trezorio/pm.pyi
@@ -13,6 +13,10 @@ EVENT_POWER_STATUS_CHANGED: int
EVENT_CHARGING_STATUS_CHANGED: int
EVENT_USB_CONNECTED_CHANGED: int
EVENT_WIRELESS_CONNECTED_CHANGED: int
+EVENT_NTC_CONNECTED_CHANGED: int
+EVENT_CHARGING_LIMITED_CHANGED: int
+EVENT_BATTERY_OCV_JUMP_DETECTED: int
+EVENT_BATTERY_TEMP_JUMP_DETECTED: int
EVENT_SOC_UPDATED: int
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.