<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aliaksandr Liapin</title>
    <description>The latest articles on DEV Community by Aliaksandr Liapin (@aliaksandrliapin).</description>
    <link>https://dev.to/aliaksandrliapin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3871018%2F2cba9b10-902d-4f3b-a120-0b3603da4d61.jpeg</url>
      <title>DEV Community: Aliaksandr Liapin</title>
      <link>https://dev.to/aliaksandrliapin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aliaksandrliapin"/>
    <language>en</language>
    <item>
      <title>The Zephyr fuel_gauge API Has No 'Battery Health' Property - So My Driver Adds One</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Sat, 06 Jun 2026 03:15:34 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/the-zephyr-fuelgauge-api-has-no-battery-health-property-so-my-driver-adds-one-dca</link>
      <guid>https://dev.to/aliaksandrliapin/the-zephyr-fuelgauge-api-has-no-battery-health-property-so-my-driver-adds-one-dca</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr1p995xpg9sg4mgj47xn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr1p995xpg9sg4mgj47xn.png" alt=" " width="800" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a moment in every open-source project where you stop asking "is my thing good?" and start asking "is my thing &lt;em&gt;the obvious choice&lt;/em&gt;?" The difference is usually &lt;strong&gt;interoperability&lt;/strong&gt;: does it plug into what people already use, or does adopting it mean betting on your bespoke API?&lt;/p&gt;

&lt;p&gt;My battery SDK (&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;ibattery-sdk&lt;/code&gt;&lt;/a&gt;, Apache-2.0) had a bespoke API. It does some genuinely useful things on tiny MCUs — estimates State of Charge, and even &lt;strong&gt;learns a battery's State of Health&lt;/strong&gt; (how worn out it is) on-device, in integer-only C. (That last part is &lt;a href="https://dev.to/aliaksandrliapin/i-made-a-battery-admit-it-was-only-73-healthy-on-device-end-to-end-1882"&gt;its own story&lt;/a&gt;.) But to &lt;em&gt;use&lt;/em&gt; any of it, you had to learn my headers.&lt;/p&gt;

&lt;p&gt;So I taught it to speak the language Zephyr developers already use: the &lt;strong&gt;&lt;code&gt;fuel_gauge&lt;/code&gt; driver API&lt;/strong&gt;. This is the story of doing that — including the part where I discovered the standard is missing the one property I care about most, and the part where verifying against the actual source code stopped me from publishing something false.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "speaking the standard" means
&lt;/h2&gt;

&lt;p&gt;Zephyr has a &lt;code&gt;fuel_gauge&lt;/code&gt; subsystem: a uniform driver interface with a &lt;code&gt;fuel_gauge_get_prop()&lt;/code&gt; call and drivers for real gauge chips (&lt;code&gt;max17048&lt;/code&gt;, &lt;code&gt;sbs_gauge&lt;/code&gt;, …). If your code is written against it, you can swap the gauge underneath without touching your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEVICE_DT_GET_ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aliaksandr_ibattery_fuel_gauge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;union&lt;/span&gt; &lt;span class="n"&gt;fuel_gauge_prop_val&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;fuel_gauge_get_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FUEL_GAUGE_VOLTAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="cm"&gt;/* µV */&lt;/span&gt;
&lt;span class="n"&gt;fuel_gauge_get_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FUEL_GAUGE_RELATIVE_STATE_OF_CHARGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="cm"&gt;/* %  */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal: make iBattery &lt;em&gt;be one of those drivers&lt;/em&gt;, so adopting it isn't a bet — it's just "the implementation behind the API you already use." I scoped it deliberately as &lt;strong&gt;read-only&lt;/strong&gt; (a software view over the SDK's existing outputs, no &lt;code&gt;set_property&lt;/code&gt;), instantiated from a devicetree node like any real gauge, and &lt;strong&gt;opt-in&lt;/strong&gt; (&lt;code&gt;CONFIG_BATTERY_FUEL_GAUGE_API&lt;/code&gt;, off by default — zero impact if you don't want it).&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of it mapped cleanly
&lt;/h2&gt;

&lt;p&gt;The SDK already computes everything the common properties want; the work was mostly &lt;strong&gt;unit conversion&lt;/strong&gt;, and the units are the fiddly part. From the header (verified, not remembered):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zephyr property&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;iBattery source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOLTAGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;µV&lt;/td&gt;
&lt;td&gt;mV × 1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CURRENT&lt;/code&gt; / &lt;code&gt;AVG_CURRENT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;µA, &lt;strong&gt;negative = discharging&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;mA × 1000, &lt;strong&gt;sign-flipped&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TEMPERATURE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1 K&lt;/td&gt;
&lt;td&gt;°C × 10 + 2731.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RELATIVE_STATE_OF_CHARGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;% (0–100)&lt;/td&gt;
&lt;td&gt;SoC, rounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REMAINING_CAPACITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;µAh&lt;/td&gt;
&lt;td&gt;coulomb counter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL_CHARGE_CAPACITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;µAh&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;learned (SoH-adjusted) capacity&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DESIGN_CAPACITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mAh&lt;/td&gt;
&lt;td&gt;rated capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CYCLE_COUNT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1/100ths&lt;/td&gt;
&lt;td&gt;cycle count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth calling out. First, the &lt;strong&gt;current sign convention is flipped&lt;/strong&gt; from mine (Zephyr says discharge is negative; I report it positive) — exactly the kind of detail that's invisible until it's wrong on real hardware. Second, a happy accident: the spec literally says &lt;code&gt;FULL_CHARGE_CAPACITY&lt;/code&gt; "might change … to determine wear" — so when I map it to the &lt;em&gt;SoH-learned&lt;/em&gt; capacity, a standard SBS-style consumer sees the battery's aging &lt;strong&gt;for free&lt;/strong&gt;, through a standard property.&lt;/p&gt;

&lt;h2&gt;
  
  
  The property the standard forgot
&lt;/h2&gt;

&lt;p&gt;Here's the twist. I went to map my headline feature — &lt;strong&gt;State of Health&lt;/strong&gt; — to its standard property… and there isn't one. I grepped the actual &lt;code&gt;fuel_gauge.h&lt;/code&gt; in two Zephyr versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; health include/zephyr/drivers/fuel_gauge.h
&lt;span class="nv"&gt;$ &lt;/span&gt;       &lt;span class="c"&gt;# (nothing)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard fuel-gauge API has &lt;code&gt;RELATIVE_STATE_OF_CHARGE&lt;/code&gt;, &lt;code&gt;ABSOLUTE_STATE_OF_CHARGE&lt;/code&gt;, capacities, cycle count… but &lt;strong&gt;no State-of-Health property at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This matters to me personally, because minutes earlier I had written — in a positioning doc — that "the Zephyr fuel_gauge API even has a State-of-Health property that maps onto our feature." That was &lt;strong&gt;wrong&lt;/strong&gt;. I'd written it from memory. Reading the header before building is the only reason it didn't ship. (Lesson, stated plainly: &lt;em&gt;don't trust your memory of an API surface — open the file.&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;But a missing standard property isn't a dead end — the API has an escape hatch (&lt;code&gt;FUEL_GAUGE_CUSTOM_BEGIN&lt;/code&gt;) for exactly this. So iBattery &lt;strong&gt;extends&lt;/strong&gt; the standard with a documented custom property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* State of Health: value in val.flags as centi-percent (7310 == 73.10 %) */&lt;/span&gt;
&lt;span class="cp"&gt;#define BATTERY_FUEL_GAUGE_PROP_SOH (FUEL_GAUGE_CUSTOM_BEGIN + 0)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the positioning flipped from a (false) "we match the standard" to a (true and &lt;em&gt;better&lt;/em&gt;) &lt;strong&gt;"we conform to the standard for the common properties, and add the battery-health metric the standard is missing."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I kept it honest: separate the math from the glue
&lt;/h2&gt;

&lt;p&gt;A Zephyr driver is hard to unit-test (it needs the device model + devicetree). So I split it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure conversion helpers&lt;/strong&gt; — plain C, no Zephyr includes (&lt;code&gt;mV→µV&lt;/code&gt;, the current sign-flip, &lt;code&gt;°C→0.1 K&lt;/code&gt;, the SoH-scaled capacity, …). These are &lt;strong&gt;host-unit-tested&lt;/strong&gt; with the same fixed-point edge cases the rest of the SDK uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thin driver&lt;/strong&gt; that just calls those helpers and packs the result into the union. It's verified by a &lt;strong&gt;build-smoke test in CI&lt;/strong&gt;: a tiny consumer that &lt;code&gt;DEVICE_DT_GET&lt;/code&gt;s the node and calls &lt;code&gt;fuel_gauge_get_prop&lt;/code&gt;, built on ESP32-C3 through the real subsystem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All the risky arithmetic is covered by fast host tests; the platform glue is proven to link.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: read it back on real hardware
&lt;/h2&gt;

&lt;p&gt;Build-passing isn't the same as &lt;em&gt;correct&lt;/em&gt;. So I added an opt-in self-check that, each cycle, reads every property back through &lt;code&gt;fuel_gauge_get_prop()&lt;/code&gt; and prints it next to the SDK's native telemetry — then ran it on a NUCLEO-L476RG with a real current sensor and a programmable supply standing in for the cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;native:  V=3133mV  I=32.60mA  SOC=1.28%  Q=219.85mAh  SOH=100.00%
[FG]:    V=3137000uV  I=-32600uA  SOC=1%  REM=219850uAh  FULL=220000uAh
         DESIGN=220mAh  SOH(flags)=10000  set_rc=-88
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line checks out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;V&lt;/code&gt; in µV = mV × 1000 ✓&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;I = -32600 µA&lt;/code&gt; — scaled &lt;strong&gt;and sign-flipped&lt;/strong&gt; (discharge negative) ✓&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REM = 219850 µAh = 219.85 mAh&lt;/code&gt; — tracks the coulomb counter exactly ✓&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SOH(flags) = 10000&lt;/code&gt; = 100.00 % ✓&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set_rc = -88&lt;/code&gt; (&lt;code&gt;-ENOSYS&lt;/code&gt;) — the read-only contract, confirmed ✓&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A battery learning it's worn out, surfaced through the &lt;em&gt;standard&lt;/em&gt; interface, validated by reading it straight back. That's the whole loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Conforming to a standard interface is unglamorous and high-leverage. It means a Zephyr developer can reach for iBattery the same way they reach for any fuel-gauge driver — and get something the dedicated gauge chips often don't bother with on cheap hardware: an &lt;strong&gt;on-device battery-health&lt;/strong&gt; number, in a couple hundred bytes of integer code.&lt;/p&gt;

&lt;p&gt;It's free and open, runs on nRF52840 / STM32L4 / ESP32-C3, and it's read-only today (reading is the 90% case; writing can come later):&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;github.com/aliaksandr-liapin/ibattery-sdk&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're building battery-powered hardware on Zephyr, enable &lt;code&gt;CONFIG_BATTERY_FUEL_GAUGE_API&lt;/code&gt;, drop in the devicetree node, and read your battery — including how &lt;em&gt;aged&lt;/em&gt; it is — through the standard API. Issues and PRs welcome.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Read-only driver, validated by reading every property back on a NUCLEO-L476RG + INA219 + PPK2 rig. The voltage is bench-emulated; the conversions, the SoH, and the read-back are real.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>iot</category>
      <category>opensource</category>
      <category>zephyr</category>
    </item>
    <item>
      <title>I Made a Battery Admit It Was Only 73% Healthy — On-Device, End to End</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Thu, 04 Jun 2026 18:45:02 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/i-made-a-battery-admit-it-was-only-73-healthy-on-device-end-to-end-1882</link>
      <guid>https://dev.to/aliaksandrliapin/i-made-a-battery-admit-it-was-only-73-healthy-on-device-end-to-end-1882</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8kj578g6n2gl10krzp7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8kj578g6n2gl10krzp7v.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Voltage lies.&lt;/p&gt;

&lt;p&gt;Put a battery under load and its terminal voltage sags. Let it rest and the voltage springs back. A naive fuel gauge watching only voltage will happily tell you a worn-out cell is "fine" right up until it falls off a cliff. The number you actually care about — &lt;em&gt;is this battery still good, or is it time to replace it?&lt;/em&gt; — isn't in the instantaneous voltage at all. It's in the &lt;strong&gt;capacity&lt;/strong&gt;: how much charge the cell can still deliver between full and empty.&lt;/p&gt;

&lt;p&gt;That quantity fades as a cell ages. Tracking it is called &lt;strong&gt;State of Health (SoH)&lt;/strong&gt;, and it's the difference between "the device says 80%" and "the device has 80% of the runtime it had when it was new."&lt;/p&gt;

&lt;p&gt;I wanted my open-source battery SDK (&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;ibattery-sdk&lt;/code&gt;&lt;/a&gt;, Apache-2.0) to learn SoH &lt;strong&gt;on the device itself&lt;/strong&gt; — no cloud model, no floating-point, on MCUs with kilobytes of RAM. This post is the story of getting that working end to end: from a coulomb integral in firmware to a faded value showing up live on a Grafana dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: learn capacity from one full→empty trip
&lt;/h2&gt;

&lt;p&gt;You don't need a PhD-grade model to estimate usable capacity. You need two anchors and an ammeter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full anchor&lt;/strong&gt; — when the cell is at its full-voltage plateau, declare "this is full" and set the coulomb counter to the rated capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discharge&lt;/strong&gt; — integrate current over time (coulomb counting). Every milliamp-hour that leaves the cell ticks the counter down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty anchor&lt;/strong&gt; — when the cell hits its empty-voltage threshold, look at how much charge actually flowed. A &lt;em&gt;healthy&lt;/em&gt; cell delivers close to its rated capacity before going empty. An &lt;em&gt;aged&lt;/em&gt; cell hits empty early — it simply has less to give.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the charge measured between those two anchors, you get the cell's real usable capacity, and &lt;code&gt;SoH = measured / rated&lt;/code&gt;. The SDK runs it through an integer EMA (so one noisy excursion doesn't whip the estimate around) and a plausibility guard (reject anything outside 30–120% of rated — that's almost certainly a glitch, not a real measurement).&lt;/p&gt;

&lt;p&gt;The whole thing is &lt;strong&gt;integer-only, ~200 bytes of flash, and zero new static RAM&lt;/strong&gt;. It's opt-in behind a Kconfig flag and rides on the coulomb counter that was already there.&lt;/p&gt;

&lt;p&gt;That's the theory. The fun part is proving it on hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't wait six months for a coin cell to age
&lt;/h2&gt;

&lt;p&gt;Here's the catch with validating a capacity-fade feature: capacity fade takes &lt;em&gt;months&lt;/em&gt;. I was not going to babysit a coin cell through a hundred discharge cycles to get a number on a chart.&lt;/p&gt;

&lt;p&gt;So I cheated — honestly. I used a &lt;strong&gt;Nordic PPK2&lt;/strong&gt; (Power Profiler Kit II) as a &lt;em&gt;programmable cell emulator&lt;/em&gt;. In Source Meter mode it's a voltage source you can sweep from ~0.8 V to 5 V while it measures current. That means I can drive the &lt;em&gt;sensed&lt;/em&gt; battery voltage anywhere I want, independently of the MCU's own supply, and walk it through a full→empty excursion in minutes instead of months.&lt;/p&gt;

&lt;p&gt;The rig (all on a breadboard):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PPK2&lt;/strong&gt; sources the emulated cell voltage.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;resistor divider&lt;/strong&gt; taps that voltage down onto an ADC pin (the firmware has an external-ADC voltage-sense mode for exactly this).&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;INA219&lt;/strong&gt; current sensor in the load path measures the real current flowing through a load resistor — that's what feeds coulomb counting.&lt;/li&gt;
&lt;li&gt;Everything shares a common ground (the ADC measures relative to it — skip this and you get garbage).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One nice detail: the divider tap sits &lt;em&gt;before&lt;/em&gt; the INA219 shunt, so the ~microamps the divider draws never pollute the current reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bring-up gotchas (because there are always gotchas)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The divider reads low.&lt;/strong&gt; My 10 kΩ/10 kΩ divider plus ADC gain came in about 9% under the true voltage. That doesn't matter for SoH — SoH is charge-based, not voltage-based — but it &lt;em&gt;does&lt;/em&gt; matter for the anchors, which are voltage thresholds. To make the firmware &lt;em&gt;read&lt;/em&gt; ≥ 2950 mV (the full-anchor threshold), I had to set the PPK2 to ~3300 mV. Measure, don't assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the excursion fast.&lt;/strong&gt; At the default rated capacity, draining at ~33 mA would take hours. I rebuilt with a small rated-capacity override so a full excursion finishes in a few minutes — same physics, faster clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-check your instruments.&lt;/strong&gt; The PPK2's own ammeter read &lt;strong&gt;31.9 mA&lt;/strong&gt; while the INA219 reported &lt;strong&gt;31.7 mA&lt;/strong&gt; — agreement under 1%. When two independent measurements agree, you trust the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it happen
&lt;/h2&gt;

&lt;p&gt;With the full anchor armed (&lt;code&gt;SOH = 100%&lt;/code&gt;), I let it discharge, then swept the PPK2 down toward empty. The serial stream tells the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V=2890 mV  I=22.4 mA  Q=5.39 mAh  SOH=100.00%
V=2623 mV             Q=5.38 mAh  SOH=100.00%
V=2351 mV             Q=5.37 mAh  SOH=100.00%
V=2079 mV  PWR=4      Q=0.01 mAh  SOH=73.10%   ← empty anchor fires
&amp;gt;&amp;gt;&amp;gt; SOH CHANGED 100.00% -&amp;gt; 73.10% &amp;lt;&amp;lt;&amp;lt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. The moment the reading crossed into the empty region, the empty anchor fired, the coulomb counter snapped to zero (nothing left), and the SDK locked in a learned &lt;strong&gt;State of Health of 73.10%&lt;/strong&gt; — a plausible, faded value, computed entirely on a Cortex-M4 with integer math.&lt;/p&gt;

&lt;h2&gt;
  
  
  From firmware to a live dashboard
&lt;/h2&gt;

&lt;p&gt;A number in a serial log is satisfying. A number on a dashboard is &lt;em&gt;convincing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The SoH value rides out over BLE in the telemetry packet (a dedicated wire-format field), into a Python gateway, into InfluxDB, and onto a Grafana panel. I confirmed it landed in the database directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="nv"&gt;_field:&lt;/span&gt; &lt;span class="k"&gt;soh&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;pct&lt;/span&gt;   &lt;span class="nv"&gt;_measurement:&lt;/span&gt; &lt;span class="k"&gt;battery&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;telemetry&lt;/span&gt;   &lt;span class="nv"&gt;_value:&lt;/span&gt; &lt;span class="mf"&gt;73.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the dashboard, the &lt;strong&gt;State of Health gauge dropped from 100% to 73.1%&lt;/strong&gt;, with the trend graph capturing the exact step down. &lt;em&gt;(Screenshot above.)&lt;/em&gt; That's the whole pipeline — firmware → BLE → gateway → time-series DB → dashboard — carrying a value the device figured out about itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it real: it survives a reboot
&lt;/h2&gt;

&lt;p&gt;A learned value you lose on power-cycle is a parlor trick. So the SDK persists the learned capacity to flash (Zephyr NVS), behind a guard that rejects the stored value if the configured rated capacity has changed.&lt;/p&gt;

&lt;p&gt;I reset the board and watched the first telemetry line after boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Battery SDK initialized OK
[v4 t=32]  V=1872 mV  SOH=73.10%   ← restored from flash, not 100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It came up reading &lt;strong&gt;73.10%, straight from flash&lt;/strong&gt; — not re-initialized to 100%. Learn it once, keep it forever (or until the next excursion refines it).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Knowing &lt;em&gt;which&lt;/em&gt; devices in a deployment are genuinely wearing out — versus just momentarily sagging under load — is the foundation for sensible battery-replacement decisions. Doing that estimation &lt;strong&gt;on-device&lt;/strong&gt;, in a couple hundred bytes of integer code, means it works on the cheapest sensor node without a cloud round-trip.&lt;/p&gt;

&lt;p&gt;It's all open source and runs today on nRF52840, STM32L4, and ESP32-C3 (Zephyr RTOS):&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;github.com/aliaksandr-liapin/ibattery-sdk&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're building battery-powered hardware and want a fuel gauge that tells you the truth about &lt;em&gt;aging&lt;/em&gt; — not just the moment-to-moment voltage — take it for a spin. Issues and PRs welcome.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built and validated with a NUCLEO-L476RG, an X-NUCLEO BLE shield, an INA219, and a PPK2 standing in for a cell. The voltage readings are emulated; the coulomb counting, the SoH math, the persistence, and the dashboard are all real.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>iot</category>
      <category>opensource</category>
      <category>zephyr</category>
    </item>
    <item>
      <title>When Soldering Doesn't Fix It: Swap the MCU</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Fri, 29 May 2026 19:35:51 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/when-soldering-doesnt-fix-it-swap-the-mcu-f58</link>
      <guid>https://dev.to/aliaksandrliapin/when-soldering-doesnt-fix-it-swap-the-mcu-f58</guid>
      <description>&lt;p&gt;&lt;em&gt;A hardware-debugging technique that uses your portable firmware codebase as the diagnostic tool. Plus the deeper bug it exposed once I got past the hardware wall.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I spent a week chasing an I2C bus that refused to ACK. By the end of it, I had soldered the breakout, replaced every jumper, swapped chips, and verified every pin assignment three times. The firmware still reported &lt;strong&gt;0 devices found&lt;/strong&gt; on every scan.&lt;/p&gt;

&lt;p&gt;What finally cracked it wasn't another tweak in the same setup. It was moving the &lt;em&gt;peripheral&lt;/em&gt; to a different &lt;em&gt;MCU&lt;/em&gt; — and discovering that the same chip, with the same wires, came up &lt;code&gt;6/6&lt;/code&gt; immediately on a NUCLEO.&lt;/p&gt;

&lt;p&gt;Here's the technique, and the bonus firmware bug it exposed once I got past the hardware wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;iBattery SDK, Phase 8a: an Adafruit INA219 current sensor wired to a nRF52840-DK over I2C, addressed at &lt;code&gt;0x40&lt;/code&gt;. From the firmware shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;uart:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i2c scan i2c@40003000
&lt;span class="go"&gt;     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
40:  -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
0 devices found on i2c@40003000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six scans in a row, all empty. A logic analyzer earlier in the project had captured the chip ACKing on the wire occasionally — so the chip was alive — but the firmware never saw it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had already ruled out
&lt;/h2&gt;

&lt;p&gt;By the time soldering came into the picture, I'd already burned through the usual suspect list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Power:&lt;/strong&gt; Adafruit "on" LED solid green → VCC + GND wires good&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin assignment:&lt;/strong&gt; triple-verified P0.26 = SDA, P0.27 = SCL, VDD on the power header&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wires:&lt;/strong&gt; replaced both data jumpers with fresh, different-colored wires&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chip:&lt;/strong&gt; swapped to a second known-good INA219, same result at every address (&lt;code&gt;0x40&lt;/code&gt;, &lt;code&gt;0x41&lt;/code&gt;, &lt;code&gt;0x44&lt;/code&gt;, &lt;code&gt;0x45&lt;/code&gt; — all &lt;code&gt;--&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firmware:&lt;/strong&gt; unchanged from a prior release that had captured clean ACKs on a logic analyzer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I soldered the male header pins onto the INA219 PCB to eliminate the press-fit class of failure entirely. Re-scan: still &lt;code&gt;0/6&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At that point I'd run out of in-place things to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: swap the MCU
&lt;/h2&gt;

&lt;p&gt;The SDK was already portable — nRF52840-DK, NUCLEO-L476RG, and ESP32-C3 all built from the same source tree, with per-board overlays and configs. I'd never thought of that portability as a &lt;em&gt;debugging tool&lt;/em&gt; before, but here we were: same firmware logic, same INA219 driver, same wire-level protocol — just running on different silicon.&lt;/p&gt;

&lt;p&gt;So I added a minimal STM32 build config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/boards/nucleo_l476rg.overlay (excerpt)&lt;/span&gt;
&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;i2c1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;ina219:&lt;/span&gt; &lt;span class="n"&gt;ina219&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;compatible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ti,ina219"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;shunt&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;milliohm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# app/boards/nucleo_l476rg_i2cscan.conf
&lt;/span&gt;&lt;span class="py"&gt;CONFIG_I2C&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;
&lt;span class="py"&gt;CONFIG_SHELL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;
&lt;span class="py"&gt;CONFIG_I2C_SHELL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;
&lt;span class="py"&gt;CONFIG_BATTERY_CURRENT_SENSE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;
&lt;span class="py"&gt;CONFIG_BATTERY_SOC_COULOMB&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;
&lt;span class="py"&gt;CONFIG_BATTERY_CAPACITY_MAH&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;220&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built and flashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;west build &lt;span class="nt"&gt;-b&lt;/span&gt; nucleo_l476rg app &lt;span class="nt"&gt;-d&lt;/span&gt; /tmp/build-stm32-scan &lt;span class="nt"&gt;--pristine&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-DEXTRA_CONF_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;boards/nucleo_l476rg_i2cscan.conf
west flash &lt;span class="nt"&gt;-d&lt;/span&gt; /tmp/build-stm32-scan &lt;span class="nt"&gt;--runner&lt;/span&gt; openocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moved the exact same wires from the nRF DK to the NUCLEO's Arduino I2C pins (PB8/PB9 = D15/D14). Reset, scanned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;uart:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i2c scan i2c@40005400
&lt;span class="go"&gt;     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
40:  40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
1 devices found on i2c@40005400
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Six out of six scans&lt;/strong&gt; ACKed &lt;code&gt;0x40&lt;/code&gt;. Telemetry started flowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[v3 t=92117] V=3323 mV T=23.56 C SOC=100.00% PWR=1 CYC=0 flags=0x00000000
I=0.20 mA Q=220.00 mAh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;flags=0x00000000&lt;/code&gt; — no &lt;code&gt;CURRENT_ERR&lt;/code&gt;. On the nRF DK this had been stuck at &lt;code&gt;0x00000020&lt;/code&gt; for two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conclusion this isolation enabled
&lt;/h2&gt;

&lt;p&gt;I now had two chips, multiple fresh wires, and the firmware all working correctly on STM32. That ruled out the chip, the wires, and the firmware in one experiment. The only remaining variable was the &lt;strong&gt;specific nRF52840-DK unit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The defect was local to that DK's P0.26 / P0.27 GPIOs — most likely damaged from repeated plug-cycle wear over many sessions. Not a chip issue, not a wiring issue, not a firmware issue. A per-unit hardware defect on a specific MCU board.&lt;/p&gt;

&lt;p&gt;I never would have isolated this cleanly without changing the MCU. Every other test I ran &lt;em&gt;kept the broken hardware in the loop.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "swap the MCU" requires
&lt;/h2&gt;

&lt;p&gt;Two preconditions, and the technique is cheap. Without them, it's not available:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Portable firmware&lt;/strong&gt; with a HAL clean enough that you can rebuild for a different MCU without rewriting drivers. In my case the SDK already supported three platforms — same code, different overlay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second known-good MCU&lt;/strong&gt; with the right peripheral on physically accessible pins. The STM32 NUCLEO's Arduino-compatible I2C1 (PB8/PB9) was already silkscreened.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you only have one platform target or your driver code is wired to one chip's idioms, this technique isn't available to you — and that itself is an argument for keeping your HAL portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus payload: a bug the swap exposed
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me. Once the chip was responding cleanly on STM32, I let it run for five minutes under a small load (~1 kΩ resistor across Vin+/Vin-, ~2.80 mA draw).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   t (ms)   V (mV)   T (C)      SOC     flags    I (mA)    Q (mAh)
 5506348     3322   23.56  100.00%  00000000      2.80     220.00
 5536382     3322   23.89  100.00%  00000000      2.80     220.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;I&lt;/code&gt; is correctly reading 2.80 mA. But &lt;code&gt;Q&lt;/code&gt; (the coulomb accumulator, nominal full = 220 mAh) is &lt;strong&gt;pinned at 220.00&lt;/strong&gt;. Over five minutes, delta = &lt;code&gt;0.0000&lt;/code&gt; mAh. SoC stuck at 100%.&lt;/p&gt;

&lt;p&gt;Discharge at 2.80 mA for 300 seconds should accumulate 0.233 mAh of charge consumed — easily resolvable with the 0.01 mAh display precision. Something was actively resetting Q.&lt;/p&gt;

&lt;p&gt;I had two suspects, and both turned out to be real bugs:&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug B — the full anchor was firing every sample
&lt;/h3&gt;

&lt;p&gt;The SoC estimator periodically calibrates the coulomb counter at known voltage anchors (&lt;code&gt;full&lt;/code&gt; at the top of the curve, &lt;code&gt;empty&lt;/code&gt; at the bottom). For CR2032, the macro that gates the "this is full" condition on current draw is &lt;code&gt;0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/intelligence/battery_soc_estimator.c&lt;/span&gt;
&lt;span class="cp"&gt;#define SOC_ANCHOR_FULL_I_X100  0    // primary cell, no current check
&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voltage_mv&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;SOC_ANCHOR_FULL_MV&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SOC_ANCHOR_FULL_I_X100&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
     &lt;span class="n"&gt;abs_current&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;SOC_ANCHOR_FULL_I_X100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reset coulomb counter to full capacity&lt;/span&gt;
    &lt;span class="n"&gt;battery_coulomb_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_mah_x100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For CR2032, &lt;code&gt;SOC_ANCHOR_FULL_I_X100 == 0&lt;/code&gt; short-circuits the &lt;code&gt;||&lt;/code&gt; to true, collapsing the entire gate to just &lt;code&gt;V &amp;gt;= 2950 mV&lt;/code&gt;. A fresh CR2032 sits well above 2.95 V under any realistic load and &lt;strong&gt;never exits that condition&lt;/strong&gt; during normal use — so the anchor fires on every sample, wiping the integration delta each time.&lt;/p&gt;

&lt;p&gt;I fixed this by making the anchor a one-shot edge-detected event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;g_full_anchor_active&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;g_empty_anchor_active&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_full_region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g_full_anchor_active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;battery_coulomb_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_mah_x100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;g_full_anchor_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;g_empty_anchor_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g_full_anchor_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// re-arm&lt;/span&gt;
    &lt;span class="n"&gt;g_empty_anchor_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The anchor calibrates &lt;em&gt;once&lt;/em&gt; when entering the region, then lets the integrator do its job until voltage leaves the region.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug A — the integrator and the estimator disagreed on Q's meaning
&lt;/h3&gt;

&lt;p&gt;This one is sneakier. Look at the integrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/intelligence/battery_coulomb.c (before)&lt;/span&gt;
&lt;span class="n"&gt;g_accumulated_mah_x1000&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta_mah_x1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Positive current (INA219's discharge convention) → positive delta → Q &lt;em&gt;increases&lt;/em&gt;. So Q is "cumulative throughput."&lt;/p&gt;

&lt;p&gt;Now look at the SoC estimator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On full anchor:&lt;/span&gt;
&lt;span class="n"&gt;battery_coulomb_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_mah_x100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Q := capacity&lt;/span&gt;

&lt;span class="c1"&gt;// On read:&lt;/span&gt;
&lt;span class="n"&gt;soc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mah_x100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;capacity_x100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// soc = Q / capacity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The estimator sets Q to capacity at full, reads it back, and computes SoC as &lt;code&gt;Q / capacity&lt;/code&gt;. That's "Q-as-remaining-capacity" semantics — the opposite of what the integrator implements.&lt;/p&gt;

&lt;p&gt;Even with Bug B fixed in isolation, the integrator would have &lt;em&gt;grown&lt;/em&gt; Q above capacity during discharge, the SoC math would have clamped at 100%, and the system would have looked superficially fine while silently misreporting forever.&lt;/p&gt;

&lt;p&gt;The fix is one character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/intelligence/battery_coulomb.c (after)&lt;/span&gt;
&lt;span class="n"&gt;g_accumulated_mah_x1000&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;delta_mah_x1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Q-as-remaining&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…plus updates to the unit tests that were enshrining the old semantics, and a new TDD regression test that drives the estimator across 100 samples at the full anchor and asserts Q strictly decreases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof on hardware
&lt;/h2&gt;

&lt;p&gt;Same rig, same load, same five-minute capture, two firmware versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0.8.3 (broken):   Q = 220.00 mAh stuck         SoC = 100.00% stuck
v0.8.4 (fixed):    Q = 219.98 → 219.75 mAh      SoC = 99.99% → 99.88%
                   Δ = -0.23 mAh in 300 s @ 2.80 mA load
                   theoretical:  -0.233 mAh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Δ matches theory to two decimal places. The anchor calibration is even visible in the first sample — Q starts at 219.98 (not 220.00), confirming the anchor fired &lt;em&gt;once&lt;/em&gt; at boot and the integrator immediately began tracking the load.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd file under "lessons"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Your portable firmware codebase is a diagnostic tool.&lt;/strong&gt; If you've spent any effort keeping a clean HAL, you've already bought yourself the swap-the-MCU technique. Use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "Analyzer sees X, firmware reads Y" usually means signal integrity, not a software bug.&lt;/strong&gt; I spent too long suspecting the firmware before the analyzer captures redirected me to the wire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Symptom A consistent with no bug" can hide compounding bugs.&lt;/strong&gt; The SoC clamping at 100% looked like correct anchor behavior. It was also masking a sign-flipped integrator. Bugs that hide each other are disproportionately expensive — fix one, the other surfaces immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Per-unit GPIO damage is real.&lt;/strong&gt; A DK plugged and unplugged dozens of times can have specific pins go bad in ways that aren't obvious without a clean comparison. The board still boots, runs other peripherals, and the GPIO reads 3.3 V at idle — only the edge-sensitivity of I2C exposes the fault.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. TDD regression tests for hardware bugs feel slow until they save you twice.&lt;/strong&gt; The new &lt;code&gt;test_cr2032_full_anchor_fires_once_not_every_sample&lt;/code&gt; test took 20 minutes to write. It documented exactly the bug, failed deterministically before the fix, passed after, and will catch any future regression from someone "simplifying" the anchor logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;aliaksandr-liapin/ibattery-sdk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Releases that came out of this debugging arc:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/releases/tag/v0.8.3" rel="noopener noreferrer"&gt;v0.8.3&lt;/a&gt; — swap to STM32, validated end-to-end&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/releases/tag/v0.8.4" rel="noopener noreferrer"&gt;v0.8.4&lt;/a&gt; — the two coulomb bugs fixed&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/releases/tag/v0.8.5" rel="noopener noreferrer"&gt;v0.8.5&lt;/a&gt; — gateway + Grafana panels surface the working signal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Captures preserved in the repo at &lt;code&gt;docs/captures/&lt;/code&gt; for anyone who wants the raw evidence. The diagnostic methodology is written up in &lt;code&gt;docs/HARDWARE_TROUBLESHOOTING.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've got a hardware bug that won't budge and your codebase is portable, try the swap. Worst case you've confirmed your firmware works on another platform.&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>zephyr</category>
      <category>iot</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why I Shipped Coulomb Counting Before the Hardware Worked</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Mon, 11 May 2026 14:54:05 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/why-i-shipped-coulomb-counting-before-the-hardware-worked-5akk</link>
      <guid>https://dev.to/aliaksandrliapin/why-i-shipped-coulomb-counting-before-the-hardware-worked-5akk</guid>
      <description>&lt;p&gt;&lt;em&gt;An embedded battery SDK story about ambitious algorithms, stubborn breadboards, and the discipline of shipping anyway.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I spent a weekend implementing coulomb counting for my open-source &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;battery monitoring SDK&lt;/a&gt;. The code is in production. The hardware doesn't actually work yet.&lt;/p&gt;

&lt;p&gt;That sentence used to make me anxious. Now I think it might be one of the most useful things I've learned about embedded engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voltage-lookup-table problem
&lt;/h2&gt;

&lt;p&gt;My SDK has been shipping state-of-charge estimation for months. The approach is simple: read the battery voltage, look it up in a curve like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4200 mV → 100%
3800 mV → ~50%
3000 mV → 0%   (LiPo cutoff)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works fine for a CR2032 coin cell that discharges in a smooth, predictable curve. For LiPo it's a disaster.&lt;/p&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The plateau region.&lt;/strong&gt; Between 3.7V and 3.8V, a LiPo can have anywhere from 30% to 70% charge. The voltage barely moves. A 10mV measurement error becomes a 10% SoC error. You can't accurate-curve your way out of physics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Load-induced sag.&lt;/strong&gt; Every time the device transmits over Bluetooth, the battery voltage drops 200-500 mV for a few milliseconds. The voltage-LUT sees this as "battery suddenly at 60%!" Then the transmit ends, voltage recovers, and the SoC jumps back to 80%. Users see a percentage that flickers like a stock ticker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cell aging is invisible.&lt;/strong&gt; A 500-cycle-old battery reads the same voltage at the same SoC as a fresh one — but it actually holds 80% of the original capacity. Voltage-LUT can't see this.&lt;/p&gt;

&lt;p&gt;The fix everyone agrees on: &lt;strong&gt;coulomb counting.&lt;/strong&gt; Measure current going in and out, integrate over time, you know exactly how much charge has moved. It's how your phone, your laptop, your EV all really do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the architecture
&lt;/h2&gt;

&lt;p&gt;I want this SDK to be a real product, not a hack. So before writing any code, I sat down to think about layering.&lt;/p&gt;

&lt;p&gt;What I landed on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INA219 chip (current sensor)
    │
    ▼ I²C
┌─────────────────────────────────────┐
│  Current HAL (battery_hal_current)  │  ← swap backends (INA219, fuel gauge, shunt+ADC)
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  Coulomb counter                    │  ← trapezoidal integration, NVS persistence
│  (integer-only, int64 accumulator)  │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  SoC estimator v2                   │  ← coulomb primary, voltage anchor at endpoints
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  Telemetry v3 (32 bytes)            │  ← BLE notifications to gateway
│  + current_ma + coulomb_mah         │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key constraints I imposed on myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Integer-only math.&lt;/strong&gt; I'm targeting nRF52840 (Cortex-M4 with FPU), STM32L4 (with FPU), and ESP32-C3 (no FPU). All math must work without floats so the same code paths run on every platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero heap allocation.&lt;/strong&gt; Embedded SDKs that malloc are embedded SDKs that mysteriously crash at 3 AM in a field deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backward compatible.&lt;/strong&gt; Existing users with voltage-only setups must continue to work with zero changes. Coulomb counting is opt-in via Kconfig.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The coulomb counter
&lt;/h2&gt;

&lt;p&gt;Here's the heart of it. Trapezoidal integration with an int64 accumulator in 0.001 mAh units (sub-mAh precision):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;battery_coulomb_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;current_ma_x100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;dt_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g_initialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BATTERY_STATUS_NOT_INITIALIZED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g_first_sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;g_prev_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_ma_x100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;g_first_sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BATTERY_STATUS_OK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/* Trapezoidal: average of previous and current sample */&lt;/span&gt;
    &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int64_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;g_prev_current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;current_ma_x100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cm"&gt;/* Convert to x1000 mAh units:
     *   delta = (avg_ma_x100 * dt_ms) / 360000
     * Keep remainder to avoid truncation drift over multi-day runs. */&lt;/span&gt;
    &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;numerator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;dt_ms&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g_remainder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;delta_x1000&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;numerator&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;360000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;g_remainder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;numerator&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;delta_x1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;g_accumulated_mah_x1000&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta_x1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;g_prev_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_ma_x100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BATTERY_STATUS_OK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remainder accumulator was a debugging gift. My first version had a 1% drift over 1800 samples — each step lost a fractional unit to integer truncation, and that loss compounded. The fix: carry the leftover into the next iteration. Now drift is mathematically zero.&lt;/p&gt;

&lt;p&gt;It survives reboot via NVS (non-volatile storage), saving every 60 seconds or whenever charge changes by more than 1 mAh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voltage anchoring
&lt;/h2&gt;

&lt;p&gt;Coulomb counting drifts. Voltage-LUT is accurate at the endpoints (full charge, cutoff) but lies in the middle.&lt;/p&gt;

&lt;p&gt;So I use voltage as a periodic calibration anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* Anchor at full charge (LiPo): voltage near max AND current is tiny
 * (CV phase complete) */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voltage_mv&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4180&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;abs_current_ma&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;battery_coulomb_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity_mah&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="cm"&gt;/* declare 100% */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Anchor at cutoff: voltage below safe threshold */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voltage_mv&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;battery_coulomb_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="cm"&gt;/* declare 0% */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "low current at full voltage" check is the trick. During charging, a LiPo sits at 4.2V for hours while current tapers. The cell isn't actually full until current drops to a trickle (the "CV phase" of CC/CV charging). If you anchor purely on voltage, you'll claim 100% an hour too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voltage smoothing layer
&lt;/h2&gt;

&lt;p&gt;While I had the project open, I added two more accuracy improvements that don't need hardware:&lt;/p&gt;

&lt;h3&gt;
  
  
  Median filter (replaces moving average)
&lt;/h3&gt;

&lt;p&gt;The existing moving-average filter dilutes BLE transmit sags but doesn't reject them. A 500 mV dip across 8 samples still pulls the average down 62 mV — enough to swing SoC by 10% on the LiPo plateau.&lt;/p&gt;

&lt;p&gt;Median filter throws outliers out completely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="nf"&gt;median_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;battery_voltage_filter_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BATTERY_VOLTAGE_FILTER_MAX_WINDOW_SIZE&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cm"&gt;/* Insertion sort a copy — n ≤ 16, fast for tiny arrays */&lt;/span&gt;
    &lt;span class="n"&gt;memcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint16_t&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost: O(n²) sort, but n is bounded at 16 — worst case 256 comparisons, executed at 0.5 Hz. The MCU spends more cycles blinking the status LED.&lt;/p&gt;

&lt;h3&gt;
  
  
  SoC slew limiter
&lt;/h3&gt;

&lt;p&gt;Even with a perfect voltage filter, the LUT has steep regions where a 20 mV change maps to a 5% SoC jump. Reality doesn't work that way: an IoT load can't physically discharge a battery 5% in 2 seconds.&lt;/p&gt;

&lt;p&gt;So I cap the rate of change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="nf"&gt;apply_slew_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;new_soc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g_first_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;dt_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_uptime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;g_prev_uptime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;max_delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="cm"&gt;/* 5%/min */&lt;/span&gt;
        &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;new_soc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;g_prev_soc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;new_soc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g_prev_soc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;max_delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;max_delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;new_soc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g_prev_soc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;max_delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;g_prev_soc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_soc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;g_prev_uptime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_uptime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;g_first_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new_soc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defense in depth: the median filter catches voltage outliers, the slew limiter catches SoC outliers if anything slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the hardware didn't work
&lt;/h2&gt;

&lt;p&gt;I wired the INA219 to an ESP32-C3 DevKitM. Default I2C pins, default address (0x40). Powered on. Watched the serial log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[INA219] not found at 0x40 (rc=-14) — check wiring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK, classic. Probably a loose breadboard contact. I swapped wires, pressed harder, tried both INA219 boards (I bought a 2-pack). Same result.&lt;/p&gt;

&lt;p&gt;I added an I²C scanner. The chip responded to the simple probe — its address showed up. But every register write got NACK'd. Reads through the proper &lt;code&gt;i2c_write_read&lt;/code&gt; API also failed. Only the bare scan worked.&lt;/p&gt;

&lt;p&gt;Switched platforms to nRF52840-DK. Same INA219 board. Same wiring topology, just different pins. Different I²C controller from Nordic instead of Espressif. &lt;strong&gt;Zero devices found on the bus.&lt;/strong&gt; The chip was completely invisible.&lt;/p&gt;

&lt;p&gt;After two hours of debugging — checking pull-up solder bridges on the DK, swapping wires, power-cycling, trying both INA219 boards — I had to admit: I don't know what's wrong. It could be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cold solder joints on the INA219 header pins&lt;/li&gt;
&lt;li&gt;Breadboard contacts at end-of-life&lt;/li&gt;
&lt;li&gt;A clock-stretching quirk in the Espressif I²C driver&lt;/li&gt;
&lt;li&gt;Static damage to one of the chips&lt;/li&gt;
&lt;li&gt;Something I haven't thought of&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;I needed a logic analyzer.&lt;/strong&gt; Without one, I'm guessing in the dark. Ordered a $10 &lt;a href="https://www.amazon.com/HiLetgo-Analyzer-Channel-Compatible-Officially/dp/B077LSG5P2" rel="noopener noreferrer"&gt;HiLetgo USB 24MHz 8-channel analyzer&lt;/a&gt; — arrives Monday.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shipping discipline
&lt;/h2&gt;

&lt;p&gt;Here's where I had a choice. The lazy option: don't release until hardware works. The disciplined option: release the software and document the gap.&lt;/p&gt;

&lt;p&gt;I chose to ship.&lt;/p&gt;

&lt;p&gt;Reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The software is complete and verified.&lt;/strong&gt; 14 host-based unit tests, all passing. 65 gateway tests, all passing. The math, the integration, the wire format, the gateway decoder — all proven. A logic analyzer is going to find a wiring problem, not a code problem.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It composes with the existing voltage-LUT path.&lt;/strong&gt; Users without an INA219 see no behavior change. The HAL has a stub backend that returns "unsupported" — the SoC estimator detects this and falls back to voltage-only. Zero regression.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Known Issues section is honest.&lt;/strong&gt; &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/docs/RELEASE_NOTES.md" rel="noopener noreferrer"&gt;The release notes&lt;/a&gt; document the exact failure mode. A potential adopter knows what they're getting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Holding releases hostage to one flaky breadboard is bad strategy.&lt;/strong&gt; I have working voltage smoothing (v0.9.0) that helps every single user immediately. Bundling it with stalled hardware validation would mean shipping nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tagged &lt;strong&gt;v0.8.0&lt;/strong&gt; for coulomb counting (software complete, hardware pending) and &lt;strong&gt;v0.9.0&lt;/strong&gt; for voltage smoothing (fully production-ready). Both are live on &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/releases" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI as accountability
&lt;/h2&gt;

&lt;p&gt;Before stopping for the day, I added a GitHub Actions workflow that builds the firmware for ESP32-C3 in three configurations — default, median filter, current sensing — on every commit. It also runs the host tests on Ubuntu and macOS, and the Python gateway tests.&lt;/p&gt;

&lt;p&gt;Now anyone landing a pull request gets concrete proof the code builds and the tests pass. The badge on the README isn't decoration; it's a contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Monday: logic analyzer arrives. I'll capture the I²C bus during boot, see exactly which byte gets NACK'd, fix the underlying issue. Ship v0.8.1 as a hardware-validation patch.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;Phase 8c&lt;/strong&gt;: Kalman filter fusion. Combine voltage and current optimally, weighted by their relative confidence (voltage is noisy under load, coulomb counting drifts over weeks). Same public API as today; just a smarter estimator inside.&lt;/p&gt;

&lt;p&gt;This is the path real BMS firmware takes — phones, EVs, medical devices. The fact that an open-source SDK targeting CR2032s and breadboards can run the same algorithm is, honestly, the point. Battery intelligence shouldn't be a moat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're building embedded products and you're not sure when to ship:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ship the software, document the hardware gap.&lt;/strong&gt; Honesty is more credible than perfection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for graceful degradation.&lt;/strong&gt; If the new sensor isn't there, fall back to the old path. No regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a logic analyzer.&lt;/strong&gt; Ten dollars buys you the difference between guessing and knowing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test what you can on the host.&lt;/strong&gt; I have ~80 unit tests that run on Linux, macOS, and Windows. They caught the integer truncation drift bug long before any breadboard was involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer your design so you can replace any part.&lt;/strong&gt; My HAL means swapping INA219 for a fuel-gauge IC later is a 100-line change, not a rewrite.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SDK is open-source and Apache 2.0. If you're working on a battery-powered IoT product and want to skip rewriting voltage curves and coulomb integrators from scratch:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;https://github.com/aliaksandr-liapin/ibattery-sdk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've solved the I²C-on-breadboard-on-Espressif puzzle before, I'd love to hear from you. The logic analyzer arrives Monday but human wisdom is faster.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next post will cover the Kalman filter fusion (Phase 8c), once Phase 8a is hardware-validated. Subscribe if you like battery math and honest engineering write-ups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>iot</category>
      <category>opensource</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Designing a HAL Abstraction That Actually Ports — Lessons from 3 MCU Families</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Tue, 14 Apr 2026 03:57:40 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/designing-a-hal-abstraction-that-actually-ports-lessons-from-3-mcu-families-om9</link>
      <guid>https://dev.to/aliaksandrliapin/designing-a-hal-abstraction-that-actually-ports-lessons-from-3-mcu-families-om9</guid>
      <description>&lt;p&gt;"Just add a HAL layer" is the most common advice in embedded development. It's also the most under-explained. After porting a battery monitoring SDK across nRF52840, STM32L476, and ESP32-C3, here's what I learned about HAL design that actually works in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I needed to read battery voltage on three MCU families. Sounds simple — until you realize each one does it completely differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;VDD Strategy&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nRF52840&lt;/td&gt;
&lt;td&gt;Direct SAADC&lt;/td&gt;
&lt;td&gt;Internal mux connects VDD to the ADC. Read it like any channel.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STM32L476&lt;/td&gt;
&lt;td&gt;VREFINT sensor&lt;/td&gt;
&lt;td&gt;ADC measures a ~1.21V internal bandgap reference. Back-calculate VDD using factory calibration data stored in ROM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESP32-C3&lt;/td&gt;
&lt;td&gt;Voltage divider&lt;/td&gt;
&lt;td&gt;Can't read VDD at all. Route battery through external resistors to a GPIO pin.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three fundamentally different approaches. Same end result: battery voltage in millivolts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: The Giant #ifdef
&lt;/h2&gt;

&lt;p&gt;The obvious first approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;read_battery_mv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="cp"&gt;#if defined(CONFIG_SOC_SERIES_NRF52X)
&lt;/span&gt;    &lt;span class="c1"&gt;// 30 lines of nRF SAADC code&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_STM32L4X)
&lt;/span&gt;    &lt;span class="c1"&gt;// 40 lines of VREFINT sensor code&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_ESP32C3)
&lt;/span&gt;    &lt;span class="c1"&gt;// 25 lines of voltage divider code&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for two platforms. By three, it's unreadable. By four, it's unmaintainable. Every new feature means touching every &lt;code&gt;#ifdef&lt;/code&gt; block.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Works: Separate the What from the How
&lt;/h2&gt;

&lt;p&gt;The key insight: &lt;strong&gt;split platform constants from platform logic&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Platform Constants (one header)
&lt;/h3&gt;

&lt;p&gt;A single header file defines &lt;em&gt;what&lt;/em&gt; each platform needs — no logic, just values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// battery_adc_platform.h&lt;/span&gt;

&lt;span class="cp"&gt;#if defined(CONFIG_SOC_SERIES_NRF52X)
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_DT_NODE    DT_NODELABEL(adc)
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_INPUT  NRF_SAADC_VDD
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_GAIN   ADC_GAIN_1_6
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_REF_MV 600
&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_STM32L4X)
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_USE_VREFINT 1
&lt;/span&gt;    &lt;span class="c1"&gt;// No ADC config needed — uses Zephyr sensor driver&lt;/span&gt;

&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_ESP32C3)
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_USE_DIVIDER    1
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_DIVIDER_RATIO  2
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_DT_NODE    DT_NODELABEL(adc0)
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_INPUT  2  // GPIO2
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_GAIN   ADC_GAIN_1_4
&lt;/span&gt;    &lt;span class="cp"&gt;#define BATTERY_ADC_VDD_REF_MV 2500
#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a fourth platform means adding 5-10 lines to this one file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Strategy Selection (one C file)
&lt;/h3&gt;

&lt;p&gt;The ADC driver selects its strategy based on the flags from Layer 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// battery_hal_adc_zephyr.c&lt;/span&gt;

&lt;span class="cp"&gt;#if defined(BATTERY_ADC_VDD_USE_VREFINT)
&lt;/span&gt;    &lt;span class="c1"&gt;// STM32: use Zephyr vref sensor driver&lt;/span&gt;
    &lt;span class="c1"&gt;// sensor_sample_fetch() → sensor_channel_get(VOLTAGE) → mV&lt;/span&gt;

&lt;span class="cp"&gt;#elif defined(BATTERY_ADC_VDD_USE_DIVIDER)
&lt;/span&gt;    &lt;span class="c1"&gt;// ESP32: raw ADC read → adc_raw_to_millivolts() → multiply by ratio&lt;/span&gt;

&lt;span class="cp"&gt;#else
&lt;/span&gt;    &lt;span class="c1"&gt;// nRF52: raw ADC read → adc_raw_to_millivolts() → done&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each block is self-contained. They don't interact. You can read one without understanding the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Everything Above (zero platform awareness)
&lt;/h3&gt;

&lt;p&gt;The voltage module, SoC estimator, telemetry collector, and BLE transport don't include any platform headers. They call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;battery_hal_adc_read_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int16_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;raw_out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;battery_hal_adc_raw_to_pin_mv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int16_t&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mv_out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. They don't know if the voltage came from SAADC, VREFINT, or a resistor divider. They don't care.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Temperature Sensor Problem
&lt;/h2&gt;

&lt;p&gt;Temperature was trickier. Each platform names its die temp sensor differently in the devicetree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Node label&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nRF52840&lt;/td&gt;
&lt;td&gt;&lt;code&gt;temp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STM32L476&lt;/td&gt;
&lt;td&gt;&lt;code&gt;die_temp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESP32-C3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;coretemp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The solution: a devicetree-driven fallback chain with zero &lt;code&gt;CONFIG_SOC_SERIES_*&lt;/code&gt; checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DT_NODE_EXISTS(DT_NODELABEL(temp))
#define BATTERY_TEMP_NODE DT_NODELABEL(temp)
#elif DT_NODE_EXISTS(DT_NODELABEL(die_temp))
#define BATTERY_TEMP_NODE DT_NODELABEL(die_temp)
#elif DT_NODE_EXISTS(DT_NODELABEL(coretemp))
#define BATTERY_TEMP_NODE DT_NODELABEL(coretemp)
#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the file — &lt;code&gt;sensor_sample_fetch()&lt;/code&gt;, &lt;code&gt;sensor_channel_get(SENSOR_CHAN_DIE_TEMP)&lt;/code&gt; — is identical across all platforms. Zephyr's sensor API handles the underlying driver differences.&lt;/p&gt;

&lt;p&gt;This pattern is more resilient than &lt;code&gt;#ifdef&lt;/code&gt; chains. If a future platform uses &lt;code&gt;temp&lt;/code&gt; (same as nRF), it works automatically with zero changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Wrong (and Fixed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake 1: Hardcoded ADC parameters
&lt;/h3&gt;

&lt;p&gt;My first NTC thermistor driver had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define NTC_ADC_ACQ_TIME  ADC_ACQ_TIME(ADC_ACQ_TIME_MICROSECONDS, 40)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked on nRF52840 and STM32. Crashed on ESP32-C3 — the driver only accepts &lt;code&gt;ADC_ACQ_TIME_DEFAULT&lt;/code&gt;. Same for &lt;code&gt;.calibrate = true&lt;/code&gt; and &lt;code&gt;.oversampling = 4&lt;/code&gt; in the ADC sequence struct.&lt;/p&gt;

&lt;p&gt;Fix: platform-conditional defaults in the NTC driver. Lesson: even "standard" Zephyr APIs have platform-specific capabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 2: Assuming VDD is readable
&lt;/h3&gt;

&lt;p&gt;I designed the API around &lt;code&gt;battery_hal_adc_read_raw()&lt;/code&gt; + &lt;code&gt;battery_hal_adc_raw_to_pin_mv()&lt;/code&gt; — a raw ADC read followed by a conversion. This maps cleanly to nRF52 and ESP32.&lt;/p&gt;

&lt;p&gt;But STM32's VREFINT path doesn't use the ADC driver at all — it uses the Zephyr sensor API (&lt;code&gt;sensor_sample_fetch&lt;/code&gt;). The "raw" value is meaningless; the real millivolts come from the sensor channel.&lt;/p&gt;

&lt;p&gt;I solved this by storing the computed millivolts in a global during &lt;code&gt;read_raw()&lt;/code&gt; and returning them from &lt;code&gt;raw_to_pin_mv()&lt;/code&gt;, ignoring the raw parameter. It's a pragmatic hack that keeps the interface stable.&lt;/p&gt;

&lt;p&gt;A cleaner design would have been a single &lt;code&gt;battery_hal_adc_read_mv(int32_t *mv_out)&lt;/code&gt; function. But by the time I hit this problem, the two-function API was used everywhere. The adapter pattern was the right tradeoff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: NVS flash page size
&lt;/h3&gt;

&lt;p&gt;I hardcoded the NVS sector size to 4096 bytes (nRF52840 flash page size). STM32L476 has 2048-byte pages. The fix: query it at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;flash_pages_info&lt;/span&gt; &lt;span class="n"&gt;page_info&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;flash_get_page_info_by_offs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flash_dev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;page_info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;nvs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sector_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page_info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: even infrastructure like flash storage has platform-specific geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Porting Checklist
&lt;/h2&gt;

&lt;p&gt;After three ports, here's my checklist for adding a new platform:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add platform section to &lt;code&gt;battery_adc_platform.h&lt;/code&gt;&lt;/strong&gt; (~10 lines)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ADC node label, VDD input channel, gain, reference, strategy flag&lt;/li&gt;
&lt;li&gt;NTC channel, gain, reference&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create board overlay&lt;/strong&gt; (&lt;code&gt;app/boards/&amp;lt;board&amp;gt;.overlay&lt;/code&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable ADC, temperature sensor, GPIO aliases&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create board config&lt;/strong&gt; (&lt;code&gt;app/boards/&amp;lt;board&amp;gt;.conf&lt;/code&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kconfig: temp source, BLE, charger pins&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Check devicetree node labels&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Die temp sensor name? Add to the fallback chain if new.&lt;/li&gt;
&lt;li&gt;ADC node name? Add to NTC driver if different.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Test ADC sequence compatibility&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the platform support oversampling? Calibrate flag? Custom acquisition time?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build and verify&lt;/strong&gt; — no core module changes should be needed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 6 requires core changes, the HAL interface is incomplete. Go back and fix the abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Flash&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Core code changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nRF52840-DK&lt;/td&gt;
&lt;td&gt;152 KB&lt;/td&gt;
&lt;td&gt;30 KB&lt;/td&gt;
&lt;td&gt;(original)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NUCLEO-L476RG&lt;/td&gt;
&lt;td&gt;38 KB&lt;/td&gt;
&lt;td&gt;10 KB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESP32-C3 DevKitM&lt;/td&gt;
&lt;td&gt;356 KB&lt;/td&gt;
&lt;td&gt;138 KB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero core module, intelligence, telemetry, or transport changes across all three ports. Only HAL files and board configs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate constants from logic.&lt;/strong&gt; A platform header with just &lt;code&gt;#defines&lt;/code&gt; is easy to extend. Logic with &lt;code&gt;#ifdefs&lt;/code&gt; is not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use devicetree fallback chains&lt;/strong&gt; instead of &lt;code&gt;CONFIG_SOC_SERIES_*&lt;/code&gt; checks where possible. They're more resilient to new platforms.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design for the weird platform.&lt;/strong&gt; If you design your HAL around the easiest platform (nRF52's direct VDD read), the others won't fit. Design for the most constrained one and the simple cases fall out naturally.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test on host, validate on hardware.&lt;/strong&gt; 69 tests run in under 2 seconds without any embedded toolchain. Hardware validation is the final step, not the first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The HAL boundary is where you'll find bugs.&lt;/strong&gt; Most issues during porting were at the HAL edge — ADC parameters that don't transfer, node labels that differ, flash page sizes that vary. The core logic never broke.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;aliaksandr-liapin/ibattery-sdk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform header&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/src/hal/helpers/battery_adc_platform.h" rel="noopener noreferrer"&gt;battery_adc_platform.h&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture docs&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/docs/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo video&lt;/strong&gt;: &lt;a href="https://www.youtube.com/watch?v=ClD72f-qkmU" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First article&lt;/strong&gt;: &lt;a href="https://dev.to/aliaksandrliapin/how-i-built-a-portable-battery-sdk-that-runs-on-3-mcu-platforms-28gp"&gt;How I Built a Portable Battery SDK That Runs on 3 MCU Platforms&lt;/a&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fegieiqgs351tuylcopkj.png" alt=" " width="800" height="935"&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>embedded</category>
      <category>c</category>
      <category>architecture</category>
      <category>iot</category>
    </item>
    <item>
      <title>How I Built a Portable Battery SDK That Runs on 3 MCU Platforms</title>
      <dc:creator>Aliaksandr Liapin</dc:creator>
      <pubDate>Fri, 10 Apr 2026 06:03:39 +0000</pubDate>
      <link>https://dev.to/aliaksandrliapin/how-i-built-a-portable-battery-sdk-that-runs-on-3-mcu-platforms-28gp</link>
      <guid>https://dev.to/aliaksandrliapin/how-i-built-a-portable-battery-sdk-that-runs-on-3-mcu-platforms-28gp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8tfepggtiai9zqct60vc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8tfepggtiai9zqct60vc.jpg" alt=" " width="800" height="388"&gt;&lt;/a&gt;Every battery-powered IoT project ends up reimplementing the same things: ADC voltage reading, SoC estimation, power state management, temperature monitoring. After doing this for the third time, I built a reusable SDK that handles all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the 4-minute demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/ClD72f-qkmU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What is iBattery SDK?
&lt;/h2&gt;

&lt;p&gt;It's an open-source C library (Apache 2.0) that provides a standardized battery intelligence layer for embedded devices running Zephyr RTOS. You init it with one call, and it gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voltage measurement&lt;/strong&gt; with moving average filtering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State-of-Charge estimation&lt;/strong&gt; via lookup table interpolation (CR2032 + LiPo)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature monitoring&lt;/strong&gt; (on-chip die sensor or external NTC thermistor)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power state machine&lt;/strong&gt; (ACTIVE → IDLE → SLEEP → CRITICAL, with charger integration)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BLE telemetry&lt;/strong&gt; streaming to a Python gateway → InfluxDB → Grafana dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire core uses ~48 bytes of static RAM, integer-only math, and zero heap allocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Portability Challenge
&lt;/h2&gt;

&lt;p&gt;The interesting part was making it run on 3 very different MCUs without changing any core code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The HAL Abstraction
&lt;/h3&gt;

&lt;p&gt;All platform-specific code lives behind a Hardware Abstraction Layer. The core modules only call HAL functions — they never include vendor headers. Porting to a new platform means implementing 5-6 HAL functions and adding board config files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three Different VDD Strategies
&lt;/h3&gt;

&lt;p&gt;Each MCU reads battery voltage differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nRF52840&lt;/strong&gt; — reads VDD directly via the SAADC internal input. Simplest approach: configure gain and reference voltage, read the ADC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STM32L476&lt;/strong&gt; — can't read VDD directly. Uses VREFINT: an internal ~1.21V bandgap reference. The ADC measures VREFINT against VDDA, and factory calibration data in ROM lets you back-calculate VDD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESP32-C3&lt;/strong&gt; — also can't read VDD. Uses an external voltage divider: two 100K resistors split the battery voltage in half, and the ADC reads the midpoint. Firmware multiplies by 2.&lt;/p&gt;

&lt;p&gt;All three strategies are hidden behind the same &lt;code&gt;battery_hal_adc_read_raw()&lt;/code&gt; / &lt;code&gt;battery_hal_adc_raw_to_pin_mv()&lt;/code&gt; interface. The temperature module, SoC estimator, and telemetry layer don't know or care which MCU they're running on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform Config in One Header
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;battery_adc_platform.h&lt;/code&gt; header is the master switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if defined(CONFIG_SOC_SERIES_NRF52X)
&lt;/span&gt;    &lt;span class="c1"&gt;// Direct SAADC, 1/6 gain, 0.6V reference&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_STM32L4X)
&lt;/span&gt;    &lt;span class="c1"&gt;// VREFINT sensor, factory calibration&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(CONFIG_SOC_SERIES_ESP32C3)
&lt;/span&gt;    &lt;span class="c1"&gt;// Voltage divider, 12dB attenuation&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a fourth platform is ~50 lines of config in this header plus a board overlay and Kconfig file. No C code changes needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Pipeline
&lt;/h2&gt;

&lt;p&gt;The SDK doesn't stop at the firmware. There's a complete telemetry pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCU (BLE GATT notifications, 24-byte packets every 2s)
  → Python gateway (bleak library, auto-reconnect)
    → InfluxDB 2.x (time-series storage)
      → Grafana (11-panel dashboard)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway includes real-time anomaly detection, battery health scoring, remaining useful life estimation, and charge cycle analysis — all accessible via CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Flash&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;BLE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nRF52840-DK&lt;/td&gt;
&lt;td&gt;152 KB&lt;/td&gt;
&lt;td&gt;30 KB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NUCLEO-L476RG&lt;/td&gt;
&lt;td&gt;38 KB&lt;/td&gt;
&lt;td&gt;10 KB&lt;/td&gt;
&lt;td&gt;Shield&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESP32-C3 DevKitM&lt;/td&gt;
&lt;td&gt;356 KB&lt;/td&gt;
&lt;td&gt;138 KB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone&lt;/span&gt;
git clone https://github.com/aliaksandr-liapin/ibattery-sdk.git

&lt;span class="c"&gt;# Run tests (no hardware needed)&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;ibattery-sdk
cmake &lt;span class="nt"&gt;-B&lt;/span&gt; build_tests tests &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cmake &lt;span class="nt"&gt;--build&lt;/span&gt; build_tests
ctest &lt;span class="nt"&gt;--test-dir&lt;/span&gt; build_tests &lt;span class="nt"&gt;--output-on-failure&lt;/span&gt;
&lt;span class="c"&gt;# 11 C test suites pass&lt;/span&gt;

&lt;span class="c"&gt;# Build for nRF52840&lt;/span&gt;
west build &lt;span class="nt"&gt;-b&lt;/span&gt; nrf52840dk/nrf52840 app

&lt;span class="c"&gt;# Start the cloud stack&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;cloud &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;gateway &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
ibattery-gateway run
&lt;span class="c"&gt;# Open http://localhost:3000 for Grafana dashboard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HAL design matters more than you think.&lt;/strong&gt; Getting the abstraction boundary right on the first platform (nRF52840) made the STM32 and ESP32 ports almost trivial.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Integer-only math is worth the constraint.&lt;/strong&gt; No FPU dependency means the code runs identically on Cortex-M4F, Cortex-M4, and RISC-V without any float promotion surprises.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zephyr's devicetree is painful but powerful.&lt;/strong&gt; Once you understand overlays and Kconfig, adding a new board is mostly config — not code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test on host, validate on hardware.&lt;/strong&gt; 69 tests run in under 1 second on macOS without any embedded toolchain. Hardware validation is the final step, not the first.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk" rel="noopener noreferrer"&gt;github.com/aliaksandr-liapin/ibattery-sdk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wiring Guide&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/docs/WIRING.md" rel="noopener noreferrer"&gt;docs/WIRING.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Reference&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/docs/SDK_API.md" rel="noopener noreferrer"&gt;docs/SDK_API.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture&lt;/strong&gt;: &lt;a href="https://github.com/aliaksandr-liapin/ibattery-sdk/blob/main/docs/ARCHITECTURE.md" rel="noopener noreferrer"&gt;docs/ARCHITECTURE.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback welcome — especially on what platforms or features you'd want next. ESP32-S3 and STM32WB are on the radar.&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>iot</category>
      <category>opensource</category>
      <category>bluetooth</category>
    </item>
  </channel>
</rss>
