diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index b1b477b2..8be18581 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -194,6 +194,17 @@ if(BUILD_TESTING) add_test(NAME trailmate_timezone_profile_smoke COMMAND trailmate_timezone_profile_smoke) + add_executable(trailmate_battery_estimator_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_battery_estimator.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/power/battery_estimator.cpp") + target_include_directories(trailmate_battery_estimator_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_battery_estimator_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_battery_estimator_smoke + COMMAND trailmate_battery_estimator_smoke) + add_executable(trailmate_team_presence_snapshot_codec_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp" diff --git a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp index ff9b9c6d..e9d722a0 100644 --- a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp +++ b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp @@ -136,6 +136,15 @@ bool supports_screen_brightness() return false; } +bool supports_configurable_battery_gauge() +{ + return false; +} + +void reload_configurable_battery_gauge() +{ +} + uint8_t screen_brightness() { return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().getBrightness(); diff --git a/boards/tdeck/include/boards/tdeck/tdeck_board.h b/boards/tdeck/include/boards/tdeck/tdeck_board.h index 6ce10fa6..6a18291a 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_board.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_board.h @@ -15,6 +15,7 @@ #include "display/DisplayInterface.h" #include "pins_arduino.h" #include "platform/esp/arduino_common/gps/GPS.h" +#include "power/battery_estimator.h" #if !defined(SCREEN_WIDTH) || !defined(SCREEN_HEIGHT) #error "SCREEN_WIDTH and SCREEN_HEIGHT must be provided via build flags (env .ini)." @@ -159,8 +160,7 @@ class TDeckBoard : public BoardBase, bool keyboard_ready_ = false; bool keyboard_pending_release_ = false; char keyboard_last_char_ = '\0'; - int last_battery_level_ = -1; - uint8_t battery_zero_streak_ = 0; + power::BatteryEstimator battery_estimator_{}; uint32_t boot_ms_ = 0; uint32_t last_trackball_ms_ = 0; uint32_t last_click_ms_ = 0; diff --git a/boards/tdeck/library.json b/boards/tdeck/library.json index aea71794..5b40008b 100644 --- a/boards/tdeck/library.json +++ b/boards/tdeck/library.json @@ -26,6 +26,9 @@ ] }, "dependencies": [ + { + "name": "core_sys" + }, { "name": "lvgl" }, diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 5283a232..2e6ddb25 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -193,29 +193,6 @@ int read_battery_mv_adc_fallback() #endif } -int battery_percent_from_mv(int mv) -{ - if (mv <= 0) - { - return -1; - } - int pct = static_cast(((mv - 3300) / 900.0f) * 100.0f); - if (pct < 0) - { - pct = 0; - } - if (pct > 100) - { - pct = 100; - } - return pct; -} - -int read_battery_percent_adc_fallback() -{ - return battery_percent_from_mv(read_battery_mv_adc_fallback()); -} - bool is_supported_gps_baud(uint32_t baud) { switch (baud) @@ -712,78 +689,19 @@ bool TDeckBoard::isCharging() int TDeckBoard::getBatteryLevel() { - int percent = -1; + power::BatteryEstimatorSample sample{}; + sample.now_ms = millis(); if (pmu_ready_) { - percent = pmu_.getBatteryPercent(); + sample.pmu_percent = pmu_.getBatteryPercent(); + sample.pmu_battery_mv = static_cast(pmu_.getBattVoltage()); + sample.charging = pmu_.isCharging(); + sample.vbus_present = pmu_.isVbusIn(); } - int adc_percent = -1; - auto ensure_adc_percent = [&adc_percent]() - { - if (adc_percent < 0) - { - adc_percent = read_battery_percent_adc_fallback(); - } - }; - - // PMU can transiently return invalid values on noisy buses. - if (percent < 0 || percent > 100) - { - ensure_adc_percent(); - if (adc_percent >= 0) - { - percent = adc_percent; - } - } - - // Guard against fake PMU 0% (common when PMU state is stale/not actually wired). - if (percent == 0) - { - ensure_adc_percent(); - if (adc_percent >= 10) - { - percent = adc_percent; - } - } - - // Guard against unrealistic sudden drops; re-check with ADC before accepting. - if (last_battery_level_ >= 0 && percent >= 0 && percent + 40 < last_battery_level_) - { - ensure_adc_percent(); - if (adc_percent >= 0) - { - percent = adc_percent; - } - } - - if (percent < 0) - { - return last_battery_level_; - } - - if (percent > 100) - { - percent = 100; - } - - // Suppress sudden fake drops to 0% while battery is clearly not empty. - bool charging = isCharging(); - if (!charging && percent == 0 && last_battery_level_ >= 15) - { - if (battery_zero_streak_ < 3) - { - battery_zero_streak_++; - return last_battery_level_; - } - } - else - { - battery_zero_streak_ = 0; - } - - last_battery_level_ = percent; - return percent; + sample.adc_battery_mv = read_battery_mv_adc_fallback(); + const power::BatteryEstimate estimate = battery_estimator_.update(sample); + return estimate.percent; } void TDeckBoard::setBrightness(uint8_t level) diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 0294a5de..80526aa4 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -16,6 +16,7 @@ #include "display/drivers/ST7796.h" #include "pins_arduino.h" +#include "platform/ui/settings_store.h" #include "ui/widgets/system_notification.h" #include @@ -283,11 +284,8 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init) // Default 1500mAh, but allow overrides from NVS (for production tuning or advanced settings). uint16_t designCapacity = 1500; uint16_t fullChargeCapacity = 1500; - Preferences prefs; - prefs.begin("power", true); - uint32_t d = prefs.getUInt("gauge_design_mah", designCapacity); - uint32_t f = prefs.getUInt("gauge_full_mah", fullChargeCapacity); - prefs.end(); + uint32_t d = ::platform::ui::settings_store::get_uint("power", "gauge_design_mah", designCapacity); + uint32_t f = ::platform::ui::settings_store::get_uint("power", "gauge_full_mah", fullChargeCapacity); if (d > 0 && d <= 10000) { designCapacity = static_cast(d); @@ -2241,11 +2239,8 @@ void TLoRaPagerBoard::reloadGaugeCapacityFromPrefs() uint16_t designCapacity = 1500; uint16_t fullChargeCapacity = 1500; - Preferences prefs; - prefs.begin("power", true); - uint32_t d = prefs.getUInt("gauge_design_mah", designCapacity); - uint32_t f = prefs.getUInt("gauge_full_mah", fullChargeCapacity); - prefs.end(); + uint32_t d = ::platform::ui::settings_store::get_uint("power", "gauge_design_mah", designCapacity); + uint32_t f = ::platform::ui::settings_store::get_uint("power", "gauge_full_mah", fullChargeCapacity); if (d > 0 && d <= 10000) { designCapacity = static_cast(d); diff --git a/modules/core_sys/include/platform/ui/device_runtime.h b/modules/core_sys/include/platform/ui/device_runtime.h index a72d4181..97aa28c3 100644 --- a/modules/core_sys/include/platform/ui/device_runtime.h +++ b/modules/core_sys/include/platform/ui/device_runtime.h @@ -30,6 +30,8 @@ MemoryStats memory_stats(); const char* firmware_version(); void handle_low_battery(const BatteryInfo& info); bool supports_screen_brightness(); +bool supports_configurable_battery_gauge(); +void reload_configurable_battery_gauge(); uint8_t screen_brightness(); void set_screen_brightness(uint8_t level); void trigger_haptic(); diff --git a/modules/core_sys/include/power/battery_estimator.h b/modules/core_sys/include/power/battery_estimator.h new file mode 100644 index 00000000..bba3be36 --- /dev/null +++ b/modules/core_sys/include/power/battery_estimator.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +namespace power +{ + +struct BatteryEstimatorSample +{ + uint32_t now_ms = 0; + int pmu_percent = -1; + int pmu_battery_mv = -1; + int adc_battery_mv = -1; + bool charging = false; + bool vbus_present = false; +}; + +enum class BatteryEstimateReason +{ + NoSample, + PmuPercent, + VoltageFallback, + ChargerStableHold, + DetachStableHold, + DropGuard, + ZeroGuard, + RateLimited, +}; + +struct BatteryEstimate +{ + BatteryEstimate() = default; + BatteryEstimate(int percent_value, BatteryEstimateReason reason_value) + : percent(percent_value), reason(reason_value) + { + } + + int percent = -1; + BatteryEstimateReason reason = BatteryEstimateReason::NoSample; +}; + +class BatteryEstimator +{ + public: + BatteryEstimate update(const BatteryEstimatorSample& sample); + int lastPercent() const { return last_percent_; } + uint32_t lastVbusDetachMs() const { return last_vbus_detach_ms_; } + void reset(); + + private: + int last_percent_ = -1; + uint32_t last_sample_ms_ = 0; + uint32_t last_vbus_detach_ms_ = 0; + bool has_last_power_state_ = false; + bool last_vbus_present_ = false; + bool last_charging_ = false; + uint8_t zero_streak_ = 0; +}; + +int batteryPercentFromLipoMillivolts(int mv); + +} // namespace power diff --git a/modules/core_sys/src/power/battery_estimator.cpp b/modules/core_sys/src/power/battery_estimator.cpp new file mode 100644 index 00000000..961b0847 --- /dev/null +++ b/modules/core_sys/src/power/battery_estimator.cpp @@ -0,0 +1,188 @@ +#include "power/battery_estimator.h" + +#include + +namespace power +{ +namespace +{ + +constexpr uint32_t kMinReadIntervalMs = 1000; +constexpr uint32_t kDetachStabilizeMs = 120000; +constexpr int kDropGuardPct = 12; +constexpr int kChargeDropGuardPct = 3; +constexpr int kAllowedUnplugDropPct = 5; +constexpr int kVoltageSupportMarginPct = 10; +constexpr int kRiseGuardPct = 2; +constexpr int kZeroGuardMinPct = 15; +constexpr uint8_t kZeroGuardCount = 3; + +int clamp_percent(int percent) +{ + if (percent < 0) + { + return -1; + } + if (percent > 100) + { + return 100; + } + return percent; +} + +int best_voltage_percent(const BatteryEstimatorSample& sample) +{ + int percent = batteryPercentFromLipoMillivolts(sample.pmu_battery_mv); + if (percent < 0) + { + percent = batteryPercentFromLipoMillivolts(sample.adc_battery_mv); + } + return percent; +} + +bool voltage_supports_last(int voltage_percent, int last_percent) +{ + return voltage_percent >= 0 && last_percent >= 0 && + voltage_percent + kVoltageSupportMarginPct >= last_percent; +} + +} // namespace + +int batteryPercentFromLipoMillivolts(int mv) +{ + if (mv <= 0) + { + return -1; + } + + static constexpr int kCurve[][2] = { + {4200, 100}, + {4100, 90}, + {4000, 80}, + {3900, 60}, + {3800, 40}, + {3700, 20}, + {3600, 10}, + {3300, 0}, + }; + + if (mv >= kCurve[0][0]) + { + return 100; + } + if (mv <= kCurve[7][0]) + { + return 0; + } + + for (std::size_t i = 0; i + 1 < (sizeof(kCurve) / sizeof(kCurve[0])); ++i) + { + const int v1 = kCurve[i][0]; + const int p1 = kCurve[i][1]; + const int v2 = kCurve[i + 1][0]; + const int p2 = kCurve[i + 1][1]; + if (mv <= v1 && mv >= v2) + { + return p2 + (mv - v2) * (p1 - p2) / (v1 - v2); + } + } + + return -1; +} + +BatteryEstimate BatteryEstimator::update(const BatteryEstimatorSample& sample) +{ + const bool was_powered = has_last_power_state_ && (last_vbus_present_ || last_charging_); + const bool is_powered = sample.vbus_present || sample.charging; + if (was_powered && !is_powered) + { + last_vbus_detach_ms_ = sample.now_ms; + } + + has_last_power_state_ = true; + last_vbus_present_ = sample.vbus_present; + last_charging_ = sample.charging; + + if (last_percent_ >= 0 && last_sample_ms_ != 0 && + sample.now_ms - last_sample_ms_ < kMinReadIntervalMs) + { + return {last_percent_, BatteryEstimateReason::RateLimited}; + } + + const int voltage_percent = best_voltage_percent(sample); + int level = clamp_percent(sample.pmu_percent); + BatteryEstimateReason reason = BatteryEstimateReason::PmuPercent; + if (level < 0) + { + level = voltage_percent; + reason = BatteryEstimateReason::VoltageFallback; + } + + if (level < 0) + { + last_sample_ms_ = sample.now_ms; + return {last_percent_, BatteryEstimateReason::NoSample}; + } + + if (last_percent_ >= 0) + { + if (!sample.charging && level > last_percent_ + kRiseGuardPct) + { + level = last_percent_; + reason = BatteryEstimateReason::DropGuard; + } + + if (sample.charging && level + kChargeDropGuardPct < last_percent_) + { + level = last_percent_; + reason = BatteryEstimateReason::ChargerStableHold; + } + + const bool in_detach_window = + last_vbus_detach_ms_ != 0 && sample.now_ms - last_vbus_detach_ms_ <= kDetachStabilizeMs; + if (!is_powered && in_detach_window && level + kAllowedUnplugDropPct < last_percent_ && + voltage_supports_last(voltage_percent, last_percent_)) + { + level = last_percent_; + reason = BatteryEstimateReason::DetachStableHold; + } + + if (!sample.charging && level + kDropGuardPct < last_percent_ && + voltage_supports_last(voltage_percent, last_percent_)) + { + level = last_percent_; + reason = BatteryEstimateReason::DropGuard; + } + } + + if (!sample.charging && level == 0 && last_percent_ >= kZeroGuardMinPct) + { + if (zero_streak_ < kZeroGuardCount) + { + ++zero_streak_; + last_sample_ms_ = sample.now_ms; + return {last_percent_, BatteryEstimateReason::ZeroGuard}; + } + } + else + { + zero_streak_ = 0; + } + + last_percent_ = level; + last_sample_ms_ = sample.now_ms; + return {last_percent_, reason}; +} + +void BatteryEstimator::reset() +{ + last_percent_ = -1; + last_sample_ms_ = 0; + last_vbus_detach_ms_ = 0; + has_last_power_state_ = false; + last_vbus_present_ = false; + last_charging_ = false; + zero_streak_ = 0; +} + +} // namespace power diff --git a/modules/core_sys/tests/test_battery_estimator.cpp b/modules/core_sys/tests/test_battery_estimator.cpp new file mode 100644 index 00000000..ce6c6dc5 --- /dev/null +++ b/modules/core_sys/tests/test_battery_estimator.cpp @@ -0,0 +1,91 @@ +#include "power/battery_estimator.h" + +#include + +namespace +{ + +power::BatteryEstimatorSample sample(uint32_t now_ms, + int pmu_percent, + int pmu_battery_mv, + int adc_battery_mv, + bool charging, + bool vbus_present) +{ + power::BatteryEstimatorSample s{}; + s.now_ms = now_ms; + s.pmu_percent = pmu_percent; + s.pmu_battery_mv = pmu_battery_mv; + s.adc_battery_mv = adc_battery_mv; + s.charging = charging; + s.vbus_present = vbus_present; + return s; +} + +void holds_full_reading_during_unplug_stabilization() +{ + power::BatteryEstimator estimator; + auto result = estimator.update(sample(1000, 100, 4200, 4190, true, true)); + assert(result.percent == 100); + + result = estimator.update(sample(3000, 82, 4120, 4110, false, false)); + assert(result.percent == 100); + assert(result.reason == power::BatteryEstimateReason::DetachStableHold); +} + +void accepts_sustained_low_voltage_after_detach_window() +{ + power::BatteryEstimator estimator; + auto result = estimator.update(sample(1000, 100, 4200, 4190, true, true)); + assert(result.percent == 100); + + result = estimator.update(sample(130000, 72, 3950, 3940, false, false)); + assert(result.percent == 72); +} + +void uses_voltage_curve_when_pmu_percent_is_invalid() +{ + power::BatteryEstimator estimator; + auto result = estimator.update(sample(1000, -1, 3900, -1, false, false)); + assert(result.percent == 60); + assert(result.reason == power::BatteryEstimateReason::VoltageFallback); +} + +void suppresses_small_unplugged_rises() +{ + power::BatteryEstimator estimator; + auto result = estimator.update(sample(1000, 74, 3970, 3970, false, false)); + assert(result.percent == 74); + + result = estimator.update(sample(3000, 79, 3980, 3980, false, false)); + assert(result.percent == 74); + assert(result.reason == power::BatteryEstimateReason::DropGuard); +} + +void guards_repeated_fake_zero_samples() +{ + power::BatteryEstimator estimator; + auto result = estimator.update(sample(1000, 45, 3820, 3820, false, false)); + assert(result.percent == 45); + + result = estimator.update(sample(3000, 0, -1, -1, false, false)); + assert(result.percent == 45); + assert(result.reason == power::BatteryEstimateReason::ZeroGuard); +} + +} // namespace + +int main() +{ + assert(power::batteryPercentFromLipoMillivolts(4200) == 100); + assert(power::batteryPercentFromLipoMillivolts(4000) == 80); + assert(power::batteryPercentFromLipoMillivolts(3900) == 60); + assert(power::batteryPercentFromLipoMillivolts(3300) == 0); + + holds_full_reading_during_unplug_stabilization(); + accepts_sustained_low_voltage_after_detach_window(); + uses_voltage_curve_when_pmu_percent_is_invalid(); + suppresses_small_unplugged_rises(); + guards_repeated_fake_zero_samples(); + return 0; +} diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 300467b2..0330345d 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -1582,6 +1582,7 @@ static void on_text_save_clicked(lv_event_t* e) return; } prefs_put_uint_ns("power", "gauge_design_mah", static_cast(value)); + device_runtime::reload_configurable_battery_gauge(); } if (g_state.editing_item->pref_key && strcmp(g_state.editing_item->pref_key, "gauge_full_mah") == 0) { @@ -1595,6 +1596,7 @@ static void on_text_save_clicked(lv_event_t* e) return; } prefs_put_uint_ns("power", "gauge_full_mah", static_cast(value)); + device_runtime::reload_configurable_battery_gauge(); } if (g_state.editing_item->pref_key && (strcmp(g_state.editing_item->pref_key, "wifi_ssid") == 0 || @@ -3268,6 +3270,11 @@ static bool should_show_item(const settings::ui::SettingItem& item) { return false; } + if ((has_pref_key(item, "gauge_design_mah") || has_pref_key(item, "gauge_full_mah")) && + !device_runtime::supports_configurable_battery_gauge()) + { + return false; + } if (meshcore) { if (has_pref_key(item, "chat_region")) return false; diff --git a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp index 01e29f9b..6e284e3b 100644 --- a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp @@ -11,6 +11,9 @@ #include "freertos/task.h" #include "platform/esp/arduino_common/battery_guard.h" #include "platform/esp/common/build_info.h" +#if defined(ARDUINO_T_LORA_PAGER) +#include "boards/tlora_pager/tlora_pager_board.h" +#endif namespace platform::ui::device { @@ -94,6 +97,22 @@ bool supports_screen_brightness() return true; } +bool supports_configurable_battery_gauge() +{ +#if defined(ARDUINO_T_LORA_PAGER) + return true; +#else + return false; +#endif +} + +void reload_configurable_battery_gauge() +{ +#if defined(ARDUINO_T_LORA_PAGER) + ::boards::tlora_pager::instance.reloadGaugeCapacityFromPrefs(); +#endif +} + uint8_t screen_brightness() { return board.getBrightness(); diff --git a/platform/linux/common/src/platform/ui/device_runtime.cpp b/platform/linux/common/src/platform/ui/device_runtime.cpp index 85c5cfd6..30e57290 100644 --- a/platform/linux/common/src/platform/ui/device_runtime.cpp +++ b/platform/linux/common/src/platform/ui/device_runtime.cpp @@ -146,6 +146,15 @@ bool supports_screen_brightness() return false; } +bool supports_configurable_battery_gauge() +{ + return false; +} + +void reload_configurable_battery_gauge() +{ +} + uint8_t screen_brightness() { return s_screen_brightness;