Skip to content

Commit c9928cc

Browse files
committed
app+board+docs: battery SoC% via fuel-gauge-composite + power source (#8)
Add a battery state-of-charge readout (issue #8) using the upstream Zephyr voltage-only fuel gauge, so no new driver is written. The M5PM1 PMIC exposes voltages only, so SoC% is derived from VBAT by OCV lookup. - board DTS: a `vbatt` voltage-divider (UNITY: output-ohms=<1>, full-ohms omitted -> raw microvolt passthrough) over m5pm1_adc ch1, feeding a `fuel_gauge` zephyr,fuel-gauge-composite (device-chemistry lithium-ion-polymer, ocv-capacity-table-0 = BATTERY_OCV_CURVE_LITHIUM_ION_POLYMER_DEFAULT, charge-full-design-microamp-hours = 250000 for the 250 mAh cell). Add m5pm1_adc channel@2 (VIN) for external-power detection. prj.conf turns on CONFIG_FUEL_GAUGE. - app: status.c reads fuel_gauge_get_prop(RELATIVE_STATE_OF_CHARGE) + VIN; battery.c holds the pure soc->bar and VIN->power-source helpers (tests/drivers/battery native_sim 4/4). PAGE_POWER shows VBAT, SoC% and the power source; the alive serial line adds bat/soc/vin. HW-017 (2026-06-05): serial `bat=4170 soc=100 vin=5142`, and the PAGE_POWER LCD shows VBAT 4172 mV / SoC 100% / src USB 5V. 100% at 4.17 V is the expected flat top (above the 4.032 V OCV curve top); the OCV interpolation is unit-tested and upstream-tested. Approximate, voltage-only (no coulomb counter, no load compensation); "currently charging" is out of scope (no reliable M5PM1 charge-status register). Evidence: evidence/20260605-hw017-battery-soc.log, evidence/PXL_20260605_070039537.MP.jpg. Docs: SDD 4.5.1, TDD HW-017 + the battery test, validation matrix, and the upstream plan (vbatt/fuel_gauge are repo-local until the M5PM1 #109961 bindings merge).
1 parent f1762e1 commit c9928cc

19 files changed

Lines changed: 412 additions & 7 deletions

app/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20.0)
22
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
33
project(m5sticks3_validation)
44

5-
target_sources(app PRIVATE src/main.c src/ui.c src/status.c src/font.c src/gfx.c)
5+
target_sources(app PRIVATE src/main.c src/ui.c src/status.c src/font.c src/gfx.c src/battery.c)
66
target_sources_ifdef(CONFIG_APP_BLE app PRIVATE src/ble.c)
77
target_sources_ifdef(CONFIG_APP_AUDIO app PRIVATE src/audio.c src/audio_dsp.c)
88
target_sources_ifdef(CONFIG_APP_IR app PRIVATE src/ir.c src/nec.c)

app/prj.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ CONFIG_DISPLAY=y
77
CONFIG_REGULATOR=y
88
CONFIG_MFD=y
99
CONFIG_ADC=y
10+
# Battery state-of-charge (issue #8): voltage-only OCV fuel gauge over VBAT.
11+
# The voltage-divider sensor + zephyr,fuel-gauge-composite driver auto-enable
12+
# from their DT nodes; the FUEL_GAUGE subsystem must be turned on here.
13+
CONFIG_FUEL_GAUGE=y
1014
CONFIG_MAIN_STACK_SIZE=4096
1115

1216
# LCD power-rail init ordering (critical, see docs HW-003).

app/src/battery.c

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (c) 2026 Hsiu-Chi Tsai
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
#include "battery.h"
7+
8+
uint8_t bat_soc_bars(int soc, uint8_t nbars)
9+
{
10+
if (soc <= 0 || nbars == 0U) {
11+
return 0U;
12+
}
13+
if (soc >= 100) {
14+
return nbars;
15+
}
16+
17+
/* Rounded: (soc * nbars + 50) / 100. soc is 1..99 here, so the product
18+
* fits an int and the result is in 0..nbars-1.
19+
*/
20+
return (uint8_t)(((soc * (int)nbars) + 50) / 100);
21+
}
22+
23+
bool bat_power_present(int vin_mv)
24+
{
25+
return vin_mv >= BAT_VIN_PRESENT_MV;
26+
}

app/src/battery.h

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2026 Hsiu-Chi Tsai
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
#ifndef M5STICKS3_BATTERY_H
7+
#define M5STICKS3_BATTERY_H
8+
9+
#include <stdbool.h>
10+
#include <stdint.h>
11+
12+
/*
13+
* Pure battery-display helpers (no Zephyr headers, native_sim-testable). The
14+
* state-of-charge percent itself comes from the upstream
15+
* zephyr,fuel-gauge-composite OCV lookup (issue #8); these only map it to a UI
16+
* bar and classify the power source from the VIN reading. Mirrors the nec.c /
17+
* audio_dsp.c pure-logic split.
18+
*/
19+
20+
/*
21+
* VIN (mV) at or above this means external 5V / USB power is present. On battery
22+
* the M5PM1 VIN reads far below this; with USB attached it reads ~5000 mV. This
23+
* is a coarse "ext power present" signal, NOT a charge-state read (the M5PM1 has
24+
* no reliable charging-status register).
25+
*/
26+
#define BAT_VIN_PRESENT_MV 4000
27+
28+
/*
29+
* Map a state-of-charge percent (0..100) to a 0..nbars bar count, rounded and
30+
* clamped. A negative soc (read error) or nbars == 0 returns 0; soc >= 100
31+
* returns nbars.
32+
*/
33+
uint8_t bat_soc_bars(int soc, uint8_t nbars);
34+
35+
/*
36+
* True if external power (USB / 5V) is present, from the VIN reading in mV. A
37+
* negative vin_mv (read error / channel unavailable) returns false.
38+
*/
39+
bool bat_power_present(int vin_mv);
40+
41+
#endif /* M5STICKS3_BATTERY_H */

app/src/main.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,10 @@ int main(void)
222222
int k1 = device_is_ready(g0) ? gpio_pin_get_raw(g0, KEY1_PIN) : -1;
223223
int k2 = device_is_ready(g0) ? gpio_pin_get_raw(g0, KEY2_PIN) : -1;
224224

225-
printk("alive uptime_ms=%lld page=%d KEY1=%d KEY2=%d imu=%d "
226-
"accel=[%d.%06d %d.%06d %d.%06d]\n",
227-
st.uptime_ms, page, k1, k2, st.imu_ok,
228-
st.accel[0].val1, abs(st.accel[0].val2),
225+
printk("alive uptime_ms=%lld page=%d KEY1=%d KEY2=%d bat=%d soc=%d "
226+
"vin=%d imu=%d accel=[%d.%06d %d.%06d %d.%06d]\n",
227+
st.uptime_ms, page, k1, k2, st.bat_mv, st.soc_pct, st.vin_mv,
228+
st.imu_ok, st.accel[0].val1, abs(st.accel[0].val2),
229229
st.accel[1].val1, abs(st.accel[1].val2),
230230
st.accel[2].val1, abs(st.accel[2].val2));
231231

app/src/status.c

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <zephyr/kernel.h>
55
#include <zephyr/device.h>
66
#include <zephyr/drivers/adc.h>
7+
#include <zephyr/drivers/fuel_gauge.h>
78

89
#if DT_NODE_HAS_STATUS(DT_ALIAS(imu0), okay)
910
static const struct device *const imu = DEVICE_DT_GET(DT_ALIAS(imu0));
@@ -18,6 +19,7 @@ static const struct device *const imu;
1819
* conversion is needed for VBAT.
1920
*/
2021
#define VBAT_CHANNEL 1U
22+
#define VIN_CHANNEL 2U
2123

2224
#if DT_NODE_HAS_STATUS(DT_NODELABEL(m5pm1_adc), okay)
2325
#define HAVE_VBAT 1
@@ -29,10 +31,29 @@ static const struct adc_channel_cfg vbat_ch_cfg = {
2931
.acquisition_time = ADC_ACQ_TIME_DEFAULT,
3032
.channel_id = VBAT_CHANNEL,
3133
};
34+
35+
/* VIN (channel 2) detects external 5V/USB power present (issue #8). */
36+
static const struct adc_channel_cfg vin_ch_cfg = {
37+
.gain = ADC_GAIN_1,
38+
.reference = ADC_REF_INTERNAL,
39+
.acquisition_time = ADC_ACQ_TIME_DEFAULT,
40+
.channel_id = VIN_CHANNEL,
41+
};
3242
#else
3343
#define HAVE_VBAT 0
3444
#endif
3545

46+
/*
47+
* Battery state-of-charge via the upstream zephyr,fuel-gauge-composite over VBAT
48+
* (issue #8): voltage-only OCV lookup, approximate, no coulomb counter.
49+
*/
50+
#if DT_NODE_HAS_STATUS(DT_NODELABEL(fuel_gauge), okay)
51+
#define HAVE_FUEL_GAUGE 1
52+
static const struct device *const fuel_gauge = DEVICE_DT_GET(DT_NODELABEL(fuel_gauge));
53+
#else
54+
#define HAVE_FUEL_GAUGE 0
55+
#endif
56+
3657
void status_init(void)
3758
{
3859
if (imu != NULL && device_is_ready(imu)) {
@@ -51,6 +72,7 @@ void status_init(void)
5172
#if HAVE_VBAT
5273
if (device_is_ready(adc)) {
5374
(void)adc_channel_setup(adc, &vbat_ch_cfg);
75+
(void)adc_channel_setup(adc, &vin_ch_cfg);
5476
}
5577
#endif
5678
}
@@ -81,10 +103,58 @@ static int read_vbat_mv(void)
81103
#endif
82104
}
83105

106+
static int read_vin_mv(void)
107+
{
108+
#if HAVE_VBAT
109+
uint16_t sample = 0;
110+
struct adc_sequence seq = {
111+
.channels = BIT(VIN_CHANNEL),
112+
.buffer = &sample,
113+
.buffer_size = sizeof(sample),
114+
.resolution = 12,
115+
};
116+
117+
if (!device_is_ready(adc)) {
118+
return -1;
119+
}
120+
121+
if (adc_read(adc, &seq) != 0) {
122+
return -1;
123+
}
124+
125+
/* M5PM1 VIN register is already in millivolts. */
126+
return (int)sample;
127+
#else
128+
return -1;
129+
#endif
130+
}
131+
132+
static int read_soc_pct(void)
133+
{
134+
#if HAVE_FUEL_GAUGE
135+
union fuel_gauge_prop_val val;
136+
137+
if (!device_is_ready(fuel_gauge)) {
138+
return -1;
139+
}
140+
141+
if (fuel_gauge_get_prop(fuel_gauge, FUEL_GAUGE_RELATIVE_STATE_OF_CHARGE,
142+
&val) != 0) {
143+
return -1;
144+
}
145+
146+
return (int)val.relative_state_of_charge;
147+
#else
148+
return -1;
149+
#endif
150+
}
151+
84152
void status_sample(struct app_status *s)
85153
{
86154
s->uptime_ms = k_uptime_get();
87155
s->bat_mv = read_vbat_mv();
156+
s->vin_mv = read_vin_mv();
157+
s->soc_pct = read_soc_pct();
88158
s->imu_ok = false;
89159
for (int i = 0; i < 3; i++) {
90160
s->accel[i].val1 = 0;

app/src/status.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
/* Telemetry gathered once per main-loop iteration. */
1111
struct app_status {
1212
int64_t uptime_ms;
13-
int bat_mv; /* -1 = not available (until P3) */
13+
int bat_mv; /* VBAT in mV; -1 = not available */
14+
int soc_pct; /* battery state-of-charge %; -1 = n/a (issue #8) */
15+
int vin_mv; /* VIN (5V/USB input) in mV; -1 = n/a */
1416
bool imu_ok;
1517
struct sensor_value accel[3]; /* x/y/z, m/s^2 */
1618
};

app/src/ui.c

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* SPDX-License-Identifier: Apache-2.0 */
22
#include "ui.h"
33
#include "gfx.h"
4+
#include "battery.h"
45

56
#ifdef CONFIG_APP_BLE
67
#include "ble.h"
@@ -164,7 +165,22 @@ static void render_power_body(const struct app_status *s)
164165
snprintf(line, sizeof(line), "VBAT: n/a ");
165166
}
166167
gfx_draw_text(MARGIN_X, body_line_y(0), HOME_FG, HOME_BG, line);
167-
gfx_draw_text(MARGIN_X, body_line_y(1), HOME_FG, HOME_BG, "M5PM1 ch1");
168+
169+
/* State-of-charge from the fuel gauge (issue #8); approximate, voltage-only. */
170+
if (s->soc_pct >= 0) {
171+
uint8_t bars = bat_soc_bars(s->soc_pct, 4);
172+
173+
snprintf(line, sizeof(line), "SoC: %d%% [%.*s%.*s] ", s->soc_pct,
174+
bars, "####", 4 - bars, "----");
175+
} else {
176+
snprintf(line, sizeof(line), "SoC: n/a ");
177+
}
178+
gfx_draw_text(MARGIN_X, body_line_y(1), HOME_FG, HOME_BG, line);
179+
180+
/* External 5V/USB power vs battery, from the M5PM1 VIN reading. */
181+
snprintf(line, sizeof(line), "src: %s ",
182+
bat_power_present(s->vin_mv) ? "USB 5V" : "battery");
183+
gfx_draw_text(MARGIN_X, body_line_y(2), HOME_FG, HOME_BG, line);
168184
}
169185

170186
#ifdef CONFIG_APP_BLE

boards/m5stack/m5stack_sticks3/m5stack_sticks3_procpu.dts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <espressif/esp32s3/esp32s3_pico_n8r8.dtsi>
1313
#include "m5stack_sticks3-pinctrl.dtsi"
1414
#include <zephyr/dt-bindings/adc/adc.h>
15+
#include <zephyr/dt-bindings/battery/battery.h>
1516
#include <zephyr/dt-bindings/input/input-event-codes.h>
1617
#include <espressif/partitions_0x0_amp.dtsi>
1718

@@ -145,6 +146,27 @@
145146
rgb-param = [40 02 14];
146147
};
147148
};
149+
150+
/*
151+
* Battery state-of-charge (issue #8). The M5PM1 exposes voltages only, so
152+
* SoC% is derived from VBAT with the upstream voltage-only fuel gauge:
153+
* m5pm1_adc ch1 (VBAT, mV) -> vbatt (unity voltage-divider, microvolt
154+
* passthrough) -> fuel_gauge (zephyr,fuel-gauge-composite, LiPo OCV curve).
155+
* See docs/02_SDD.md section 4.5.1.
156+
*/
157+
vbatt: vbatt {
158+
compatible = "voltage-divider";
159+
io-channels = <&m5pm1_adc 1>;
160+
output-ohms = <1>; /* unity; full-ohms omitted => unscaled passthrough */
161+
};
162+
163+
fuel_gauge: fuel-gauge {
164+
compatible = "zephyr,fuel-gauge-composite";
165+
source-primary = <&vbatt>;
166+
device-chemistry = "lithium-ion-polymer";
167+
ocv-capacity-table-0 = <BATTERY_OCV_CURVE_LITHIUM_ION_POLYMER_DEFAULT>;
168+
charge-full-design-microamp-hours = <250000>; /* 250 mAh cell */
169+
};
148170
};
149171

150172
&usb_serial {
@@ -221,6 +243,17 @@
221243
zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
222244
zephyr,resolution = <12>;
223245
};
246+
247+
/* Channel 2 = VIN (5V/USB input, reported in mV). Used to
248+
* detect external power present (issue #8).
249+
*/
250+
channel@2 {
251+
reg = <2>;
252+
zephyr,gain = "ADC_GAIN_1";
253+
zephyr,reference = "ADC_REF_INTERNAL";
254+
zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
255+
zephyr,resolution = <12>;
256+
};
224257
};
225258
};
226259
};

docs/02_SDD.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,52 @@ b0), a battery low-voltage cutoff (BATT_LVP 0x08), a power-source readback
133133
Any "full PMIC" work is therefore charge-enable + power-source/insertion status,
134134
not a fuel gauge.
135135

136+
#### 4.5.1 Battery state-of-charge (issue #8, voltage-only OCV fuel gauge)
137+
138+
Because the M5PM1 exposes voltages only (no SoC register, above), a battery
139+
percentage is derived externally with the standard upstream Zephyr
140+
`zephyr,fuel-gauge-composite` (voltage-only open-circuit-voltage lookup), so no
141+
new driver is written. The data path is:
142+
143+
```
144+
m5pm1_adc ch1 (VBAT, mV) -> vbatt (voltage-divider, UNITY) -> fuel_gauge
145+
(zephyr,fuel-gauge-composite) -> fuel_gauge_get_prop(RELATIVE_STATE_OF_CHARGE)
146+
```
147+
148+
- `vbatt` is a `compatible = "voltage-divider"` sensor wrapping
149+
`io-channels = <&m5pm1_adc 1>`. The divider is UNITY: `output-ohms = <1>` and
150+
`full-ohms` is OMITTED, which is the binding's documented passthrough ("if
151+
full-ohms is absent the unscaled voltage is returned"); the sensor reports
152+
`SENSOR_CHAN_VOLTAGE` in microvolts. The unit math is exact because the M5PM1
153+
ADC reports true millivolts with `ref_internal = 4096 mV` at 12-bit, so
154+
`adc_raw_to_microvolts(raw_mv) = raw_mv * 4096/4096 * 1000 = raw_mv * 1000`.
155+
- `fuel_gauge` is `zephyr,fuel-gauge-composite` with `source-primary = <&vbatt>`,
156+
`device-chemistry = "lithium-ion-polymer"`,
157+
`ocv-capacity-table-0 = <BATTERY_OCV_CURVE_LITHIUM_ION_POLYMER_DEFAULT>` (the
158+
in-tree 11-point microvolt curve from `dt-bindings/battery/battery.h`), and
159+
`charge-full-design-microamp-hours = <250000>` (the 250 mAh cell).
160+
`fuel-gauge-channels` is left UNSET so the composite queries the generic
161+
`SENSOR_CHAN_VOLTAGE` the voltage-divider implements (setting it true would
162+
request `SENSOR_CHAN_GAUGE_VOLTAGE`, which the voltage-divider rejects with
163+
`-ENOTSUP`). The composite tries the source's gauge SoC channel, gets
164+
`-ENOTSUP`, and falls back to `battery_soc_lookup(ocv_table, voltage)/1000`.
165+
- The app reads the percentage with
166+
`fuel_gauge_get_prop(dev, FUEL_GAUGE_RELATIVE_STATE_OF_CHARGE, &val)` ->
167+
`val.relative_state_of_charge` (0-100).
168+
169+
Accuracy is bounded and stated honestly: this is voltage-only SoC with no coulomb
170+
counter, so it is approximate, has no load compensation (a current draw sags VBAT
171+
and under-reads), and ideally wants a rest period for a true open-circuit
172+
reading. The default LiPo curve tops at 4.032 V, so a freshly charged cell
173+
(~4.18 V on this board) reads a flat 100% until it drops below 4.03 V. A
174+
board-profiled curve is a future refinement.
175+
176+
Power source: "on external power vs battery" is shown from the M5PM1 VIN reading
177+
(`m5pm1_adc` channel 2): VIN above a threshold means USB/5V is present. The chip
178+
also has the precise `PWR_SRC 0x04` enum (5VIN/5VINOUT/BAT), used as a follow-up;
179+
a clean "currently charging" status is not reliably exposed, so charge-state is
180+
out of scope (not claimed).
181+
136182
### 4.6 Comprehensive demo application architecture (v0.6)
137183

138184
The v0.6 release evolves `app/` from the single-file validation loop into a

0 commit comments

Comments
 (0)