battery: centralized LiPo OCV curve + ADC multiplier corrections

Add a board-aware battery SOC system replacing the two independent
linear approximations that existed in the UI helpers.

New helpers/battery_curve.{h,c}: a 21-point (5% step) OCV lookup table
with integer linear interpolation. The default generic LiPo curve is a
weak symbol — any board can override it by dropping a battery_curve.c
into its board directory. CMakeLists.txt selects the board-specific file
when present, falling back to the generic.

Board.h gains getBattPercent() (default 0); ZephyrBoard implements it
via battery_curve_lookup().

Board-specific curves added for boards with measured cell data:
t1000_e, rak_wismesh_tag, thinknode_m6, sensecap_solar, wio_tracker_l1.

ADC multiplier corrections applied across all nRF52840 boards:
- Boards using a correctly-derived 3600×ratio formula get +0.5% to
  compensate for nRF SAADC gain error (7200→7236, 6300→6332, etc.)
- rak_wismesh_tag, rak3401_1watt, gat562_30s had multipliers copied
  from Arduino's 3.0V AREF formula; corrected to 3600×1.73×1.005=6259
- xiao_nrf52840 (10911) left unchanged — empirically calibrated value
  above the theoretical, assumed already correct for that hardware

get adc.multiplier now reports current mV reading and the board's
curve 100% target, making field calibration self-guiding.
This commit is contained in:
liquidraver
2026-06-03 13:21:06 +02:00
parent 8761a82d93
commit d37b401033
26 changed files with 266 additions and 15 deletions
+15
View File
@@ -436,6 +436,21 @@ target_sources(app PRIVATE
helpers/fatal_reboot.c
)
# ========== Battery Curve Selection ==========
# Board-specific battery_curve.c wins over the generic default.
# To add a curve for a board, create boards/<platform>/<board>/battery_curve.c
# without __weak — the linker prefers the non-weak generic only as a fallback.
file(GLOB_RECURSE _BOARD_BATTERY_CURVE
"${CMAKE_CURRENT_SOURCE_DIR}/boards/*/${BOARD_BASE}/battery_curve.c")
if(_BOARD_BATTERY_CURVE)
list(GET _BOARD_BATTERY_CURVE 0 _BOARD_BATTERY_CURVE)
message(STATUS "ZephCore Battery: board-specific curve (${_BOARD_BATTERY_CURVE})")
target_sources(app PRIVATE "${_BOARD_BATTERY_CURVE}")
else()
message(STATUS "ZephCore Battery: generic LiPo curve")
target_sources(app PRIVATE helpers/battery_curve.c)
endif()
# Native-Linux (native_sim) runtime setup: force real-time clock mode so the
# simulated clock tracks wall time (required for the real SX126x radio's
# BUSY/DIO1 timing). Bakes in the equivalent of the --rt command-line flag.
+6
View File
@@ -3,6 +3,7 @@
*/
#include "ZephyrBoard.h"
#include "battery_curve.h"
#include <zephyr/kernel.h>
#include <zephyr/sys/reboot.h>
#include <zephyr/drivers/sensor.h>
@@ -146,6 +147,11 @@ uint16_t ZephyrBoard::getBattMilliVolts()
#endif
}
uint8_t ZephyrBoard::getBattPercent()
{
return battery_curve_lookup(&battery_curve_default, getBattMilliVolts());
}
bool ZephyrBoard::setAdcMultiplier(float multiplier)
{
#if DT_NODE_EXISTS(DT_PATH(zephyr_user)) && \
+1
View File
@@ -12,6 +12,7 @@ namespace mesh {
class ZephyrBoard : public MainBoard {
public:
uint16_t getBattMilliVolts() override;
uint8_t getBattPercent() override;
float getMCUTemperature() override;
bool setAdcMultiplier(float multiplier) override;
float getAdcMultiplier() const override;
@@ -146,7 +146,7 @@
/* Battery sense: AIN3 = P0.05 with the GAT562 divider (~1.73x) */
zephyr,user {
io-channels = <&adc 3>;
vbat-mv-multiplier = <6232>; /* mv = raw * multiplier / 4096 */
vbat-mv-multiplier = <6259>; /* 1.73:1 divider, 3.6V ref; corrected from Arduino 3.0V AREF + nRF SAADC gain */
};
};
@@ -182,7 +182,7 @@
* voltage divider ratio = 4.9 (ADC_MULTIPLIER from variant.h).
* multiplier = 3600 * 4.9 = 17640
*/
vbat-mv-multiplier = <17640>;
vbat-mv-multiplier = <17728>; /* 4.9:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
aliases {
@@ -72,7 +72,7 @@
/* Battery ADC channel */
zephyr,user {
io-channels = <&adc 7>;
vbat-mv-multiplier = <10650>; /* 1M/510k divider (2.96x), 3.6V ref */
vbat-mv-multiplier = <10709>; /* 2.96:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
};
@@ -190,7 +190,7 @@
* with ADC_REF_INTERNAL (0.6V) × gain 1/6 × divider 2.0. */
zephyr,user {
io-channels = <&adc 2>;
vbat-mv-multiplier = <7200>;
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* ---- E-Paper display (MIPI DBI SPI) ----
@@ -77,7 +77,7 @@
/* Battery ADC: P0.31 (AIN7), 150K+150K voltage divider (2:1) */
zephyr,user {
io-channels = <&adc 7>;
vbat-mv-multiplier = <7200>;
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
};
@@ -78,7 +78,7 @@
/* Battery ADC: P0.31 (AIN7), 2:1 voltage devider, 3.6V ref */
zephyr,user {
io-channels = <&adc 7>;
vbat-mv-multiplier = <7200>;
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
};
@@ -94,7 +94,7 @@
* vbat-mv-multiplier = round(3 * 1.73 * 1.187 * 1000) = 6161 */
zephyr,user {
io-channels = <&adc 3>;
vbat-mv-multiplier = <6161>;
vbat-mv-multiplier = <6259>; /* 1.73:1 divider, 3.6V ref; corrected from Arduino 3.0V AREF + nRF SAADC gain */
};
};
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* RAK WisMesh Tag single-cell LiPo OCV curve — 21 points at 5% steps.
* 11-point base linearly interpolated to 5% resolution.
* Index 0 = 100% (4160 mV), index 20 = 0% (2990 mV).
*
* Notable shape: very compressed upper range (100% = 4160 mV, not 4190),
* flat plateau 3.723.76 V, steep cliff below 3.62 V to 2990 mV cutoff.
*/
#include "battery_curve.h"
static const uint16_t ocv_rak_wismesh_tag[21] = {
4160, 4090, 4020, 3980, 3940, /* 100 .. 80% */
3905, 3870, 3840, 3810, 3785, /* 75 .. 55% */
3760, 3750, 3740, 3730, 3720, /* 50 .. 30% */
3700, 3680, 3650, 3620, 3305, /* 25 .. 5% */
2990, /* 0% */
};
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_rak_wismesh_tag,
.num_points = 21,
.num_cells = 1,
};
@@ -138,7 +138,7 @@
* applied to 12-bit average with default ref (GAIN_1_6 + 0.6V internal = 3.6V FS). */
zephyr,user {
io-channels = <&adc 3>;
vbat-mv-multiplier = <6161>;
vbat-mv-multiplier = <6259>; /* 1.73:1 divider, 3.6V ref; corrected from Arduino 3.0V AREF + nRF SAADC gain */
};
/* ---- Buzzer on PWM0 ---- */
@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* SenseCAP Solar single-cell LiPo OCV curve — 21 points at 5% steps.
* 11-point base linearly interpolated to 5% resolution.
* Index 0 = 100% (4200 mV), index 20 = 0% (2786 mV).
*
* Notable shape: steeper overall slope than a standard LiPo, consistent with
* a high-impedance cell measured under load rather than at true OCV. Low
* cutoff (2786 mV) reflects the solar node's extended discharge budget.
*/
#include "battery_curve.h"
static const uint16_t ocv_sensecap_solar[21] = {
4200, 4093, 3986, 3954, 3922, /* 100 .. 80% */
3867, 3812, 3773, 3734, 3689, /* 75 .. 55% */
3645, 3586, 3527, 3473, 3420, /* 50 .. 30% */
3350, 3281, 3184, 3087, 2936, /* 25 .. 5% */
2786, /* 0% */
};
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_sensecap_solar,
.num_points = 21,
.num_cells = 1,
};
@@ -91,7 +91,7 @@
zephyr,user {
io-channels = <&adc 7>;
/* Zephyr: ADC_GAIN_1_6 + ADC_REF_INTERNAL = 3600mV full-scale; 1:3 voltage divider → 3600*3 */
vbat-mv-multiplier = <10800>;
vbat-mv-multiplier = <10854>; /* 3:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
aliases {
@@ -0,0 +1,25 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* T1000-E single-cell LiPo OCV curve — 21 points at 5% steps.
* 11-point base linearly interpolated to 5% resolution.
* Index 0 = 100% (4190 mV), index 20 = 0% (3100 mV).
*
* Notable shape: very flat plateau 3.723.82 V, steep cliff below 3.64 V.
*/
#include "battery_curve.h"
static const uint16_t ocv_t1000_e[21] = {
4190, 4116, 4042, 3999, 3957, /* 100 .. 80% */
3921, 3885, 3852, 3820, 3798, /* 75 .. 55% */
3776, 3761, 3746, 3735, 3725, /* 50 .. 30% */
3710, 3696, 3670, 3644, 3372, /* 25 .. 5% */
3100, /* 0% */
};
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_t1000_e,
.num_points = 21,
.num_cells = 1,
};
@@ -102,7 +102,7 @@
/* Battery ADC channel reference for ZephyrBoard */
zephyr,user {
io-channels = <&adc 0>;
vbat-mv-multiplier = <7200>; /* 2:1 voltage divider, 3.6V ref */
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* Buzzer on PWM0 channel 0 (P0.25) with enable on P1.05 */
@@ -151,7 +151,7 @@
/* 150K+150K voltage divider (2:1) on AIN2 (P0.04) */
zephyr,user {
io-channels = <&adc 2>;
vbat-mv-multiplier = <7200>;
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* ---- Buzzer on PWM0 ---- */
@@ -96,7 +96,7 @@
* With GAIN_1_6 + internal ref (3600mV full scale) × 2 = 7200 multiplier. */
zephyr,user {
io-channels = <&adc 3>;
vbat-mv-multiplier = <7200>;
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* Buzzer on PWM0 channel 0 (P0.23, PIN_BUZZER=23) */
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* ThinkNode M6 single-cell LiPo OCV curve — 21 points at 5% steps.
* 11-point base linearly interpolated to 5% resolution.
* Index 0 = 100% (4080 mV), index 20 = 0% (3450 mV).
*
* Notable shape: lower full-charge ceiling (4080 mV) suggesting a conservative
* charge termination; smooth linear discharge with no sharp cliff at the bottom.
*/
#include "battery_curve.h"
static const uint16_t ocv_thinknode_m6[21] = {
4080, 4035, 3990, 3962, 3935, /* 100 .. 80% */
3907, 3880, 3852, 3825, 3797, /* 75 .. 55% */
3770, 3742, 3715, 3687, 3660, /* 50 .. 30% */
3632, 3605, 3577, 3550, 3500, /* 25 .. 5% */
3450, /* 0% */
};
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_thinknode_m6,
.num_points = 21,
.num_cells = 1,
};
@@ -94,7 +94,7 @@
* With GAIN_1_6 + internal ref (3600mV full scale) × 1.75 = 6300 multiplier. */
zephyr,user {
io-channels = <&adc 4>;
vbat-mv-multiplier = <6300>;
vbat-mv-multiplier = <6332>; /* 1.75:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* Board power enable (PIN_PWR_EN = P0.27) — must be HIGH for peripherals */
@@ -0,0 +1,29 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Wio Tracker L1 battery curve — single-cell LiPo, 21 points at 5% steps.
*
* 100% = 4190 mV: observed charge ceiling on this board after the ADC
* multiplier correction (vbat-mv-multiplier = 7236). Voltages above this
* clamp to 100% in the lookup.
*
* The discharge shape below 4190 mV follows the generic LiPo profile.
* Replace this table with measured discharge data for the specific cell
* installed if a more accurate curve is needed.
*/
#include "battery_curve.h"
static const uint16_t ocv_wio_tracker_l1[21] = {
4190, 4120, 4050, 4020, 3990, /* 100 .. 80% */
3940, 3890, 3845, 3800, 3760, /* 75 .. 55% */
3720, 3675, 3630, 3580, 3530, /* 50 .. 30% */
3475, 3420, 3360, 3300, 3200, /* 25 .. 5% */
3100, /* 0% */
};
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_wio_tracker_l1,
.num_points = 21,
.num_cells = 1,
};
@@ -126,7 +126,7 @@
/* Battery ADC channel reference for ZephyrBoard */
zephyr,user {
io-channels = <&adc 7>;
vbat-mv-multiplier = <7200>; /* 2:1 voltage divider, 3.6V ref */
vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */
};
/* Buzzer on PWM0 channel 0 (P1.00) - no enable pin needed */
+9 -1
View File
@@ -4,6 +4,7 @@
*/
#include "CommonCLI.h"
#include "battery_curve.h"
#include <helpers/TxtDataHelpers.h>
#include <helpers/AdvertDataHelpers.h>
#include <adapters/board/ZephyrBoard.h>
@@ -507,7 +508,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
if (adc_mult == 0.0f) {
strcpy(reply, "Error: unsupported by this board");
} else {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)adc_mult);
uint16_t mv = _board->getBattMilliVolts();
uint16_t target_mv = battery_curve_default.ocv_mv[0];
if (mv > 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f (%u mV, target >= %u mV for 100%%)",
(double)adc_mult, mv, target_mv);
} else {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f (no ADC reading)", (double)adc_mult);
}
}
} else if (memcmp(config, "rxduty", 6) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle);
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
#include "battery_curve.h"
/*
* Generic single-cell LiPo OCV curve 21 points at 5% steps.
* Derived from measured LiPo discharge data; 11-point base expanded to
* 21 points by linear interpolation for better knee resolution.
*
* Index 0 = 100% (4190 mV), index 20 = 0% (3100 mV).
* 100% is 4190 mV, not 4200 mV reflects observed charge ceiling across
* multiple boards after the charger transitions to maintenance mode.
*/
static const uint16_t ocv_generic[21] = {
4190, 4120, 4050, 4020, 3990, /* 100 .. 80% */
3940, 3890, 3845, 3800, 3760, /* 75 .. 55% */
3720, 3675, 3630, 3580, 3530, /* 50 .. 30% */
3475, 3420, 3360, 3300, 3200, /* 25 .. 5% */
3100, /* 0% */
};
__attribute__((weak))
const battery_curve_t battery_curve_default = {
.ocv_mv = ocv_generic,
.num_points = 21,
.num_cells = 1,
};
uint8_t battery_curve_lookup(const battery_curve_t *curve, uint16_t mv)
{
if (mv == 0) {
return 0;
}
uint16_t cell_mv = mv / curve->num_cells;
uint8_t n = curve->num_points;
if (cell_mv >= curve->ocv_mv[0]) {
return 100;
}
if (cell_mv <= curve->ocv_mv[n - 1]) {
return 0;
}
/* Each segment spans 100/(n-1) percent. For n=21 this is exactly 5. */
uint8_t seg_pct = (uint8_t)(100 / (n - 1));
for (uint8_t i = 1; i < n; i++) {
if (cell_mv >= curve->ocv_mv[i]) {
uint8_t pct_low = (uint8_t)(100 - (uint16_t)i * 100 / (n - 1));
uint32_t num = (uint32_t)(cell_mv - curve->ocv_mv[i]) * seg_pct;
uint16_t den = curve->ocv_mv[i - 1] - curve->ocv_mv[i];
return (uint8_t)(pct_low + num / den);
}
}
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
const uint16_t *ocv_mv; /* descending — index 0 = 100%, last index = 0% */
uint8_t num_points;
uint8_t num_cells; /* 1 for single-cell packs (almost always 1) */
} battery_curve_t;
/* Default curve — __weak so a board-specific battery_curve.c can override it. */
extern const battery_curve_t battery_curve_default;
/* Returns 0100 %. Returns 0 if mv == 0 (no battery / ADC absent). */
uint8_t battery_curve_lookup(const battery_curve_t *curve, uint16_t mv);
#ifdef __cplusplus
}
#endif
+1
View File
@@ -17,6 +17,7 @@ namespace mesh {
class MainBoard {
public:
virtual uint16_t getBattMilliVolts() = 0;
virtual uint8_t getBattPercent() { return 0; }
virtual float getMCUTemperature() { return NAN; }
virtual bool setAdcMultiplier(float multiplier) { (void)multiplier; return false; }
virtual float getAdcMultiplier() const { return 0.0f; }