<?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>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>
