ElectronicZoologyfield notes from the garage
Wireless • ESP32

How to build an ESP-NOW remote
charger monitor with INA219
and OLED

Board: ESP32 Dev Board (38-pin, both sender and receiver)
Sensor: INA219 (I2C 0x40)
Display: SSD1306 128x64 OLED (I2C 0x3C)
Topic: ESP-NOW, unicast, INA219, wireless sensor data
✓ Confirmed Working

What this guide builds on

What You Will Learn

The first ESP-NOW guide used broadcast mode with a dummy payload. The sender put its own MAC address in a packet and sent it to every ESP-NOW device in range. No target, no real data, no wiring. That is the right place to start because it removes every variable except the radio itself.

This guide adds two things. First, the sender targets a specific receiver by MAC address instead of broadcasting. Second, the payload is real: voltage and current from an INA219 sitting inline on a TP4056 charging an 18650 cell.

The result is a charger monitor split across two boards. The sender lives next to the charger. The receiver and its display go wherever you want them. You can watch a full charge cycle from the other side of the room.

The single-board version of this build is worth reading first if you have not seen the INA219 and SSD1306 together yet. This guide assumes you have both libraries installed and understand how the INA219 sits inline on the charge line. See How to build an INA219 battery charger monitor with ESP32 and OLED →

How Unicast Differs From Broadcast

In broadcast mode the sender uses FF:FF:FF:FF:FF:FF as the destination. Any ESP-NOW device nearby picks up the packet. The send callback always reports success because there is no specific recipient to confirm delivery.

Unicast changes both of those things. You replace the broadcast address with a single receiver's MAC, register that MAC as a named peer, and send only to it. The send callback now tells you something real: ESP_NOW_SEND_SUCCESS means the receiver acknowledged the packet at the radio layer. FAIL means it did not. Walk the sender out of range and you will see FAIL appear in serial.

The receiver side does not change much. The receive callback works the same way regardless of whether the packet came from a broadcast or a unicast sender. The difference is entirely on the sender: you have to know the receiver's MAC before you flash, and you have to paste it into the sketch.

Get the Receiver's MAC

Before you flash the sender, you need the receiver's MAC address. Flash the get-your-MAC sketch from the first ESP-NOW guide to the receiver board, open serial monitor at 115200, and copy the address it prints.

It looks like this:

MAC address: AA:BB:CC:DD:EE:FF

Copy it into a text editor or write it down. You will paste it into the sender sketch before flashing.

Hardware

Sender

  • ESP32 Dev Board (30-pin or 38-pin)
  • INA219 breakout - I2C address 0x40
  • TP4056 module with battery protection
  • Single 18650 cell and holder
  • Separate USB power for the TP4056 and the ESP32

Libraries (sender)

  • Adafruit INA219 by Adafruit

Receiver

  • ESP32 Dev Board (30-pin or 38-pin)
  • SSD1306 128x64 OLED (4-pin I2C, address 0x3C)

Libraries (receiver)

  • Adafruit SSD1306 by Adafruit
  • Adafruit GFX Library by Adafruit (dependency - install when prompted)

The sender has no display. The receiver has no INA219. Each board only carries what it needs.

Wiring

Sender

The INA219 sits inline on the positive line between the TP4056 and the battery, the same as the single-board build. The ESP32 and INA219 are on separate USB power from the TP4056.

Positive line: TP4056 B+ → INA219 VIN+ → INA219 VIN- → Battery +

Negative line: Battery - → TP4056 B- → Common GND (ESP32, INA219)

ESP32 PinConnects to
GPIO 21 (SDA)INA219 SDA
GPIO 22 (SCL)INA219 SCL
3.3VINA219 VCC
GNDINA219 GND, battery negative

Receiver

The OLED wires directly to the ESP32 with no other components.

ESP32 PinConnects to
GPIO 21 (SDA)OLED SDA
GPIO 22 (SCL)OLED SCL
3.3VOLED VCC
GNDOLED GND

The Sender Sketch

A few things to understand before you read the code:

The struct. PacketData holds two floats: voltage and current_mA. Both sender and receiver define the same struct. The sender fills it from the INA219. The receiver reads it from the packet. If the structs differ between sketches the data unpacks as garbage.

Registering the peer. In broadcast mode you add FF:FF:FF:FF:FF:FF as a peer and it just works. Here you add the receiver's specific MAC. If esp_now_add_peer() fails, the address you pasted does not match what is actually in the array. Check the format: each byte from the MAC (AA) becomes 0xAA in the array.

The send callback now means something. In broadcast mode OnDataSent always reported success. In unicast the receiver acknowledges the packet at the radio layer. If the sender is in range and the receiver is running, you see OK. If you walk the sender out of range, you see FAIL. That is real delivery feedback, not a formality.

Paste the receiver's MAC into receiverMAC[] before uploading. Each byte in the address (AA:BB:CC:DD:EE:FF) becomes a hex value in the array (0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF).

/*
 * We stand on the shoulders of giants when we build
 * with knowledge gained from others' efforts.
 * That doesn't make us giants. Be humble.
 * Create with care. Open source is the way.
 *
 * ESP-NOW Remote Monitor - Sender
 * --------------------------------
 * Reads voltage and current from an INA219 and sends both
 * to a specific receiver board over ESP-NOW every two seconds.
 *
 * Unicast mode: paste the receiver's MAC address into receiverMAC[]
 * before flashing. Flash the get-your-MAC sketch to the receiver
 * first if you have not already noted its address.
 *
 * Board:   ESP32 Dev Board (38-pin)
 * Library: Adafruit INA219 by Adafruit
 *
 * Wiring:
 *   INA219: SDA -> GPIO 21   SCL -> GPIO 22
 *   INA219: VCC -> 3.3V      GND -> GND
 *   INA219 inline: VIN+ from TP4056 B+, VIN- to battery +
 *
 * Open source - MIT Licence
 * Electronic Zoology - field notes from the garage
 * https://electroniczoology.com/guides/how-to-build-esp-now-ina219-remote-monitor
 */

#include <WiFi.h>
#include <esp_now.h>
#include <Wire.h>
#include <Adafruit_INA219.h>

// --- Paste the receiver's MAC address here ---
uint8_t receiverMAC[] = { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF };

#define SEND_INTERVAL_MS 2000

typedef struct {
  float voltage;
  float current_mA;
} PacketData;

PacketData outgoing;
esp_now_peer_info_t peerInfo;
Adafruit_INA219 ina219;

void OnDataSent(const wifi_tx_info_t *tx_info, esp_now_send_status_t status) {
  Serial.print("Send status: ");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "OK" : "FAIL");
}

void setup() {
  Serial.begin(115200);
  delay(500);

  WiFi.mode(WIFI_STA);
  delay(100);

  Serial.print("Sender MAC: ");
  Serial.println(WiFi.macAddress());

  Wire.begin(21, 22);

  if (!ina219.begin()) {
    Serial.println("INA219 not found - check wiring");
    while (true) delay(1000);
  }

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    while (true) delay(1000);
  }

  esp_now_register_send_cb(OnDataSent);

  memcpy(peerInfo.peer_addr, receiverMAC, 6);
  peerInfo.channel = 0;
  peerInfo.encrypt = false;

  if (esp_now_add_peer(&peerInfo) != ESP_OK) {
    Serial.println("Failed to add peer - check receiver MAC");
    while (true) delay(1000);
  }

  Serial.println("Sender ready");
}

void loop() {
  static uint32_t lastSend = 0;

  if (millis() - lastSend >= SEND_INTERVAL_MS) {
    lastSend = millis();

    outgoing.voltage    = ina219.getBusVoltage_V();
    outgoing.current_mA = max(0.0f, ina219.getCurrent_mA());

    Serial.printf("Sending: %.3fV  %.1fmA\n", outgoing.voltage, outgoing.current_mA);

    esp_err_t result = esp_now_send(
      receiverMAC,
      (uint8_t *)&outgoing,
      sizeof(outgoing)
    );

    if (result != ESP_OK) {
      Serial.println("Send error");
    }
  }
}

The Receiver Sketch

Two things to understand about the receive callback:

volatile bool newPacket. The callback runs in a different execution context from loop(). Marking the flag volatile tells the compiler not to cache it in a register. Without it, loop() might check a stale copy and miss packets. Set the flag in the callback, do the work in loop().

The recv_info parameter. ESP32 core v3.x changed the callback signature - the first argument is now esp_now_recv_info_t *recv_info instead of uint8_t *mac. The sender's MAC is still available at recv_info->src_addr if you need it.

/*
 * We stand on the shoulders of giants when we build
 * with knowledge gained from others' efforts.
 * That doesn't make us giants. Be humble.
 * Create with care. Open source is the way.
 *
 * ESP-NOW Remote Monitor - Receiver
 * -----------------------------------
 * Receives voltage and current readings from a remote INA219 sender
 * and displays them on an SSD1306 OLED with dual scrolling graphs.
 *
 * Two views rotate every VIEW_ROTATE_MS milliseconds:
 *
 *   Live view:   one sample per packet (every 2s), 128 samples = ~4 min
 *                Shows recent current draw as a scrolling bar graph.
 *
 *   Charge view: one sample per minute, 128 samples = ~2 hours
 *                Shows the full CC/CV curve as the charge progresses.
 *
 * Both views share the same numeric top line (live voltage and current).
 * Shows "signal lost" if no packet arrives within 10 seconds.
 *
 * Board:   ESP32 Dev Board (38-pin)
 * Library: Adafruit SSD1306 + Adafruit GFX by Adafruit
 *
 * Wiring:
 *   OLED: SDA -> GPIO 21   SCL -> GPIO 22
 *   OLED: VCC -> 3.3V      GND -> GND
 *
 * Open source - MIT Licence
 * Electronic Zoology - field notes from the garage
 * https://electroniczoology.com/guides/how-to-build-esp-now-ina219-remote-monitor
 */

#include <WiFi.h>
#include <esp_now.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH    128
#define SCREEN_HEIGHT   64
#define OLED_RESET      -1
#define OLED_ADDRESS    0x3C

#define GRAPH_MAX_MA    1200.0f
#define CHARGE_SAMPLE_MS 60000   // one sample/min, 128 samples = ~2hr
#define VIEW_ROTATE_MS  5000
#define TIMEOUT_MS      10000

#define GRAPH_Y         10
#define GRAPH_H         (SCREEN_HEIGHT - GRAPH_Y)   // 54px
#define BUF_SIZE        SCREEN_WIDTH                // 128 points

typedef struct {
  float voltage;
  float current_mA;
} PacketData;

PacketData incoming;
volatile bool newPacket = false;

// Live buffer - one entry per received packet
float   liveBuf[BUF_SIZE];
uint8_t liveHead = 0;
bool    liveFull = false;

// Charge buffer - one entry per minute
float   chargeBuf[BUF_SIZE];
uint8_t chargeHead = 0;
bool    chargeFull = false;

bool showLive = true;
uint32_t lastPacket = 0;

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

void OnDataRecv(const esp_now_recv_info_t *recv_info, const uint8_t *incomingData, int len) {
  memcpy(&incoming, incomingData, sizeof(incoming));
  newPacket = true;
}

void addToLive(float mA) {
  liveBuf[liveHead] = mA;
  liveHead = (liveHead + 1) % BUF_SIZE;
  if (liveHead == 0) liveFull = true;
}

void addToCharge(float mA) {
  chargeBuf[chargeHead] = mA;
  chargeHead = (chargeHead + 1) % BUF_SIZE;
  if (chargeHead == 0) chargeFull = true;
}

void drawGraph(float* buf, uint8_t head, bool full) {
  uint8_t count    = full ? BUF_SIZE : head;
  uint8_t startIdx = full ? head     : 0;

  for (uint8_t i = 0; i < count; i++) {
    uint8_t idx    = (startIdx + i) % BUF_SIZE;
    float   sample = buf[idx];

    if (sample < 0)            sample = 0;
    if (sample > GRAPH_MAX_MA) sample = GRAPH_MAX_MA;

    int barH = (int)((sample / GRAPH_MAX_MA) * (float)(GRAPH_H - 1));
    if (barH < 1 && sample >= 1.0f) barH = 1;

    int x = i;
    int y = GRAPH_Y + (GRAPH_H - 1) - barH;
    display.drawFastVLine(x, y, barH, SSD1306_WHITE);
  }
}

void drawDisplay(float volts, float mA) {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  // --- Numeric line ---
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(volts, 2);
  display.print("V");

  // View label centred
  const char* label = showLive ? "LIVE" : "CHARGE";
  int labelW = strlen(label) * 6;
  display.setCursor((SCREEN_WIDTH - labelW) / 2, 0);
  display.print(label);

  // mA right-aligned
  char maBuf[10];
  if (mA < 1.0f) {
    snprintf(maBuf, sizeof(maBuf), "<1mA");
  } else {
    snprintf(maBuf, sizeof(maBuf), "%dmA", (int)mA);
  }
  int maWidth = strlen(maBuf) * 6;
  display.setCursor(SCREEN_WIDTH - maWidth, 0);
  display.print(maBuf);

  // --- Divider ---
  display.drawFastHLine(0, GRAPH_Y - 1, SCREEN_WIDTH, SSD1306_WHITE);

  // --- Graph ---
  if (showLive) {
    drawGraph(liveBuf, liveHead, liveFull);
  } else {
    if (chargeHead == 0 && !chargeFull) {
      display.setTextSize(1);
      display.setCursor(10, GRAPH_Y + 18);
      display.print("< 1 min of data");
    } else {
      drawGraph(chargeBuf, chargeHead, chargeFull);
    }
  }

  display.display();
}

void drawSignalLost() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(20, 24);
  display.print("signal lost");
  display.display();
}

void setup() {
  Serial.begin(115200);
  delay(500);

  WiFi.mode(WIFI_STA);
  delay(100);

  Serial.print("Receiver MAC: ");
  Serial.println(WiFi.macAddress());

  memset(liveBuf,   0, sizeof(liveBuf));
  memset(chargeBuf, 0, sizeof(chargeBuf));

  Wire.begin(21, 22);

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    Serial.println("SSD1306 not found - check wiring and OLED_ADDRESS");
    while (true) delay(1000);
  }

  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("ESP-NOW receiver");
  display.println("waiting...");
  display.display();

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    while (true) delay(1000);
  }

  esp_now_register_recv_cb(OnDataRecv);

  Serial.println("Receiver ready - waiting for packets");

  lastPacket = millis();
}

void loop() {
  static uint32_t lastCharge = 0;
  static uint32_t lastRotate = 0;
  static bool signalLost = false;

  uint32_t now = millis();

  if (newPacket) {
    newPacket  = false;
    lastPacket = now;
    signalLost = false;

    Serial.printf("Received: %.3fV  %.1fmA\n", incoming.voltage, incoming.current_mA);

    addToLive(incoming.current_mA);

    if (now - lastCharge >= CHARGE_SAMPLE_MS) {
      lastCharge = now;
      addToCharge(incoming.current_mA);
    }

    drawDisplay(incoming.voltage, incoming.current_mA);
  }

  if (now - lastRotate >= VIEW_ROTATE_MS) {
    lastRotate = now;
    showLive = !showLive;
  }

  if (!signalLost && now - lastPacket >= TIMEOUT_MS) {
    signalLost = true;
    drawSignalLost();
    Serial.println("Signal lost");
  }
}

What You Will See

Flash the receiver first. Its OLED shows "ESP-NOW receiver / waiting..." and holds there until packets arrive.

Flash the sender with the receiver's MAC pasted in. Open serial monitor on the sender at 115200. Every two seconds:

Sending: 3.752V  843.0mA
Send status: OK

The receiver's OLED updates to show the live reading. Walk the sender to the edge of range and Send status: FAIL appears in serial. After ten seconds with no packet the OLED switches to "signal lost". Walk back in range and the display recovers on the next successful packet.

Peer add failure at startup. If the sender reports Failed to add peer and halts, the MAC in receiverMAC[] does not match the receiver's actual address. Reflash the get-your-MAC sketch to the receiver, re-read the serial output, and compare byte by byte.

Going Further

Adjust the live window

The live buffer adds one sample per received packet. At a 2-second send interval, 128 samples covers roughly 4 minutes of history. Halve the send interval on the sender to get 2 minutes, or double it to get 8. The charge buffer is independent and always samples once per minute regardless of packet rate.

Two cells at once

Add a second INA219 at address 0x41 (jumper A0 high on the breakout) to the sender. Extend PacketData to carry four floats: voltage_a, current_a, voltage_b, current_b. The receiver displays both channels. Update the struct on both sides before flashing.

Put the display on a round screen

The GC9A01 240x240 round TFT is a natural remote gauge but it is not a drop-in swap. The SSD1306 uses I2C and the Adafruit SSD1306 library. The GC9A01 uses SPI and TFT_eSPI, which has a completely different drawing API, different wiring, and requires configuring a User_Setup.h file before anything compiles. The sender sketch does not change. The receiver needs to be rewritten from scratch around TFT_eSPI calls. See How to wire the GC9A01 round display with ESP32 →

Send readings back

ESP-NOW is bidirectional. The receiver can register the sender as a peer and send acknowledgement packets back: a timestamp, a display-received confirmation, or a command to change the sample rate. Both boards become sender and receiver simultaneously.

Skip the MAC paste with peer discovery

This guide requires you to know the receiver's MAC before flashing the sender. The sender can instead broadcast a "who's there?" packet on boot. Any receiver that hears it reads the sender's MAC from the mac parameter in its receive callback, registers it as a peer, and broadcasts its own MAC back. The sender catches the reply, registers the receiver, and both boards switch to unicast. No addresses copied between sketches, no fixed flashing order. Either board can reboot and the handshake runs again automatically.

Take a look at some of our other guides

Log the full charge curve to flash How to use LittleFS on ESP32 →
Put the remote display on a round TFT How to wire the GC9A01 round display with ESP32 →