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