DEV Community

Cover image for Serving a Live Battery Dashboard from ESP32-C3 with Microdot and MicroPython
Developer Service
Developer Service

Posted on • Originally published at developer-service.blog

Serving a Live Battery Dashboard from ESP32-C3 with Microdot and MicroPython

You've been there: the hardware works, the sensors are wired up, and somewhere inside main.py a print() is faithfully reporting voltage every second into a serial terminal that nobody will ever open again.

The gap between "working prototype" and "something you'd actually show someone" is often just a few hundred lines of web code - and on MicroPython, that's easier than you might expect.

This article walks you through building a live battery charge dashboard that runs entirely on an ESP32-C3 Super Mini. The device connects to your local Wi-Fi, hosts a small web page using Microdot, and serves real-time voltage, current, and power readings from an INA219 power monitor while a TP4056 charges an 18650 cell.

Any browser on the same network can open the page and watch the charge progress unfold.

The hardware is a simple, but useful circuit. The core lesson here is Microdot: how to think about routes, JSON APIs, and a minimal browser UI that polls the device.

These patterns apply to anything you'd wire to a micro-controller, like temperature sensors, fuel gauges, motion detectors, so even if battery monitoring isn't your use case, this structure is.


What You Will Build

The finished project is a single-page dashboard served directly from the ESP32-C3. It shows:

  • Bus voltage (V) - measured by the INA219 on the battery side
  • Charge current (mA) - derived from the INA219 shunt voltage (in series with battery)
  • Power (mW) - computed as V × mA
  • Last update time - so you can immediately see if the connection is stale
  • Connection status indicator - a clear visual signal that the browser is actively receiving data

It is intentionally small. On microcontrollers, the fastest route to reliability is a small surface area: tiny payloads, tiny pages, and simple control flow.

This is the running dashboard while charging a 18650 battery:

Charge Dashboard when charging a 18650 cell

You won't find React, WebSockets, or authentication here - not because those are wrong, but because they distract from the architectural lesson.


Why Microdot?

Before diving into code, it's worth understanding why Microdot is the right tool here rather than, say, raw sockets or a stripped-down asyncio HTTP handler.

MicroPython already trims the Python standard library to fit in a few hundred kilobytes. Most full web frameworks - Flask or FastAPI - assume a runtime environment with considerably more headroom.

Microdot was designed specifically for constrained environments: it's small enough to live comfortably on an ESP32, and it gives you the ergonomics that make web code readable.

The mental model maps directly to Flask if you know it:

from microdot import Microdot

app = Microdot()

@app.route('/')
def index(request):
    return "Hello from ESP32", 200, {'Content-Type': 'text/plain'}

app.run(port=80)
Enter fullscreen mode Exit fullscreen mode

That's it. An app object, route decorators, and sensible response handling. Microdot supports both sync and async handlers, which matters on ESP32 where uasyncio is your concurrency model and blocking the event loop kills Wi-Fi stability.

For this project, the full Microdot API surface we'll use is:

Feature Used for
@app.route('/') Serving the HTML dashboard page
@app.route('/api/metrics') JSON endpoint the browser polls
Returning tuples (body, status, headers) Setting content type, handling errors
Sync handlers (def) Keeping the project small and readable

Nothing too exotic. That's the point.


System Overview

There are two parallel "flows" in this project, and keeping them separate in your head will save debugging time later.

Data flow:

INA219 (I²C) → ESP32-C3 (MicroPython) → Microdot routes → browser
Enter fullscreen mode Exit fullscreen mode

Power and charge flow:

5V USB → TP4056 → 18650 cell
                 ↓
           INA219 (in series with battery leg)
                 ↓
           ESP32-C3 (powered from USB separately)
Enter fullscreen mode Exit fullscreen mode

The INA219 sits in the battery leg - measuring what actually goes into the cell - while the ESP32 runs the web server and polls the sensor over I²C.

These are logically independent: the charger doesn't care about the web server, and the web server doesn't care about charging chemistry.


Hardware

You need four main components, plus a few passives and connectors.

  • ESP32-C3 Super Mini: The ESP32-C3 Super Mini is a good choice here for several reasons: it's compact, supports Wi-Fi, runs MicroPython well, and its USB-C connector makes firmware updates painless.

  • INA219: The INA219 is a current/power monitor that communicates over I²C. It measures two things directly:

    • Bus voltage - the voltage on the load side of the shunt resistor (this is your battery voltage)
    • Shunt voltage - the tiny voltage drop across the shunt resistor caused by current flowing through it
    • From the shunt voltage and a known shunt resistance, you get current: I = V_shunt / R_shunt.
  • TP4056: The TP4056 is a single-cell Li-ion linear charger. Its charging algorithm follows two phases:

    • Constant Current (CC): The charger delivers a fixed current (typically 1A on modules with a 1.2kΩ programming resistor) until the cell voltage reaches approximately 4.2V.
    • Constant Voltage (CV): The charger holds 4.2V and the current tapers toward zero as the cell reaches full charge.
  • 18650 Cell: Use a genuine cell from a reputable brand. Counterfeit 18650s with inflated capacity ratings are widespread and can be unsafe.


Safety

Li-ion cells store a significant amount of energy and can fail dangerously if mistreated. Pay attention, especially on a circuit you're building on a breadboard.

  • Never charge unattended, especially during initial bring-up.
  • Stop immediately if anything gets warm to the touch, smells unusual, swells, or hisses.
  • Use a proper enclosure or at minimum keep the cell away from flammable materials on your bench.
  • Don't short the battery terminals - ever.
  • Don't let the cell discharge below ~2.5V.

Wiring Summary

In this build, the INA219 is placed in the battery leg (in series), so it measures the current actually going into (or coming out of) the cell:

TP4056 B+ ──── INA219 IN+ ──── INA219 IN- ──── Cell +
Enter fullscreen mode Exit fullscreen mode

All grounds must be common (ESP32, INA219, and TP4056 share GND).

For reference, the full wiring diagram:

Wiring Diagram

This is how it looks like on a breadboard:

Breadboard Wiring

Note: You might see in this photo the TP4056 connections and think the polarity is reversed. I have made a mistake and soldered them reversed, in the picture the TP4046 B- is red and B+ is black.

Remember: The TP4056 B- should be to ground and TP4056 B+ goes to INA219 V+. Soldered them correctly, don' follow my example...


Software: Project Structure

The project is split into four files that live on the ESP32 filesystem:

├── main.py      # Wi-Fi connection + server startup
├── web.py       # Microdot app, routes, and inline HTML/JS
├── sensor.py    # INA219 init and read_metrics()
└── ina219.py    # Low-level INA219 driver
Enter fullscreen mode Exit fullscreen mode

This structure makes the project maintainable. web.py owns the HTTP contract. sensor.py owns hardware access. Neither knows about the other's implementation details. The route handlers in web.py call sensor.py functions and translate the results into HTTP responses.

The article only shows only the important snippets below. The complete, working code (including the full HTML/CSS) lives in the repo: https://github.com/nunombispo/microdot-article

First, you will need to install the microdot library, which for this case means copying the microdot.py from the repository to the device. Full install instructions are here:

sensor.py - Hardware Reads

The goal is: hide the INA219/I²C setup behind one function, and expose a single read_metrics() that returns a JSON-friendly dict. The sensor is initialized lazily (first call), which keeps main.py minimal.

_ina = None

def _get_ina219():
    global _ina
    if _ina is not None:
        return _ina

    # NOTE: Update pins to match your ESP32-C3 board wiring.
    i2c = I2C(0, scl=Pin(9), sda=Pin(8), freq=400_000)
    _ina = INA219(i2c=i2c, address=0x40)
    _ina.configure_32v_2a()
    return _ina

def read_metrics() -> dict:
    ina = _get_ina219()
    voltage_v = ina.bus_voltage_v()
    current_ma = ina.current_ma()
    power_mw = voltage_v * current_ma
    return {
        "voltage_v": voltage_v,
        "current_ma": current_ma,
        "power_mw": power_mw,
    }
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices here:

  • _ina is module-level state. The INA219 is initialized once and reused across requests. Reinitializing I²C on every poll is wasted work and can cause intermittent bus issues.
  • The API adds a timestamp separately (in web.py) so sensor.py stays focused on measurement, not transport/UI concerns.

web.py - Routes and Inline UI

There are only two routes: one serves the page, one serves JSON metrics. Microdot supports both sync and async def handlers; this project uses sync handlers to keep things simple.

from microdot import Microdot, Response
from sensor import read_metrics

app = Microdot()
Response.default_content_type = "text/html; charset=utf-8"

def _mono_ms() -> int:
    ticks_ms = getattr(time, "ticks_ms", None)
    return int(ticks_ms()) if ticks_ms else int(time.time() * 1000)

@app.route('/')
def index(_request):
    return HTML  # full HTML/CSS lives in the repo

@app.route('/api/metrics')
def api_metrics(_request):
    try:
        metrics = read_metrics()
        metrics["ts_ms"] = _mono_ms()
        return metrics
    except Exception:
        return {"error": "sensor_unavailable"}, 503
Enter fullscreen mode Exit fullscreen mode

Notice how thin the handlers are. The API route is intentionally just “read the sensor, return JSON”, with a single stable error shape for the UI. That keeps the HTTP layer boring - which is exactly what you want on a microcontroller.

On the browser side, the core loop is a simple poll-and-render (full page in the repo):

async function poll() {
  const r = await fetch('/api/metrics', { cache: 'no-store' });
  if (!r.ok) throw new Error('HTTP ' + r.status);
  const d = await r.json();
  voltageEl.textContent = d.voltage_v.toFixed(2) + ' V';
  currentEl.textContent = d.current_ma.toFixed(2) + ' mA';
}
setInterval(poll, 1000);
Enter fullscreen mode Exit fullscreen mode

main.py - Boot and Server Start

The startup sequence is short and deliberate: connect Wi‑Fi, initialize hardware, then start the server.

import time
import network
from web import app


_ENV = _read_dotenv()
WIFI_SSID = _ENV.get("WIFI_SSID", "")
WIFI_PASSWORD = _ENV.get("WIFI_PASSWORD", "")

def connect_wifi(ssid: str, password: str, timeout_s: int = 20) -> str:
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(ssid, password)
        start = time.time()
        while not wlan.isconnected():
            if time.time() - start > timeout_s:
                raise RuntimeError("Wi-Fi connect timeout")
            time.sleep(0.25)
    return wlan.ifconfig()[0]

def main() -> None:
    if not WIFI_SSID or not WIFI_PASSWORD:
        raise RuntimeError("Missing WIFI_SSID/WIFI_PASSWORD in .env")
    ip = connect_wifi(WIFI_SSID, WIFI_PASSWORD)
    print("Wi-Fi connected, IP:", ip)
    print("Open http://%s/ in a browser" % ip)
    app.run(port=80, debug=False)

main()
Enter fullscreen mode Exit fullscreen mode

The startup sequence matters: connect Wi‑Fi first, then start the server.


What to Expect During a Charge Session

With the INA219 measuring battery-side current, a typical TP4056 session has a recognizable shape:

Constant Current phase (early):

  • Current is relatively flat - usually 800–1000 mA on a standard 1A module, though weak USB supplies often limit this to 600–700 mA.
  • Voltage climbs steadily from around 3.6–3.7V toward 4.2V.
  • This phase lasts 1–2 hours depending on cell capacity and charge current.

Constant Voltage phase (late):

  • Voltage plateaus near 4.2V.
  • Current begins a gradual exponential decay.
  • The TP4056 considers the cell full when current drops below approximately C/10 (for a 2000mAh cell, that's around 200 mA).
  • This phase can last another hour.

Let' see the CC phase in action:

Charging 18650 battery

And if it gets disconnect for some reason:

Dashboard disconnected from ESP32


Conclusion

Micro-controllers don’t need a “real web stack” to feel like a real product. With Microdot, you get the same clean mental model as Flask - routes, JSON, and a tiny UI - without dragging in heavy dependencies or complex infrastructure.

In this build you now have:

  • A stable contract: GET /api/metrics returns a small JSON payload that any UI can consume.
  • A maintainable split: sensor logic stays in sensor.py, HTTP logic stays in web.py.

If you build something similar for a different sensor, keep the same pattern: one hardware module that returns a dict, one Microdot JSON route, and a minimal page that polls and renders.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Top comments (0)