MicroPython: ESP-NOW with ESP32—Receive Data from Multiple Boards (many-to-one)

In this MicroPython guide, we’ll show you how to set up an ESP32 to receive and display data from multiple ESP32 boards using the ESP-NOW communication protocol (many-to-one configuration). We’ll build a sample project where sender boards transmit sensor data in JSON format, and a single receiver board collects the data and displays it on an OLED display. The tutorial will use two sender boards as an example, but the setup can be easily scaled to include more.

MicroPython: ESP-NOW with ESP32—Receive Data from Multiple Boards (many-to-one)

Using Arduino IDE? Follow this tutorial instead: ESP-NOW with ESP32: Receive Data from Multiple Boards (many-to-one).

Prerequisites – MicroPython Firmware

To follow this tutorial, you need MicroPython firmware installed on your ESP32 or ESP8266 boards. You also need an IDE to write and upload the code to your board. We suggest using Thonny IDE:

New to MicroPython? Check out our eBook: MicroPython Programming with ESP32 and ESP8266 eBook (2nd Edition)

Introducing ESP-NOW

ESP-NOW is a wireless communication protocol developed by Espressif that allows multiple ESP32 or ESP8266 boards to exchange small amounts of data without using Wi-Fi or Bluetooth. ESP-NOW does not require a full Wi-Fi connection (though the Wi-Fi controller must be turned on), making it ideal for low-power and low-latency applications like sensor networks, remote controls, or data exchange between boards.

ESP-NOW - ESP32 Logo

ESP-NOW uses a connectionless communication model, meaning devices can send and receive data without connecting to a router or setting up an access point (unlike HTTP communication between boards). It supports unicast (sending data to a specific device using its MAC address) and broadcast (sending data to all nearby devices using a broadcast MAC address) messaging.

New to ESP-NOW? Read our getting started guide: MicroPython: ESP-NOW with ESP32 (Getting Started).

Project Overview

This tutorial shows how to set up an ESP32 board to receive data from multiple ESP32 boards via ESP-NOW communication protocol (many-to-one configuration) as shown in the following figure.

ESP-NOW ESP32 Receive Data from Multiple Boards (Many to One configuration) diagram
  • One ESP32 board acts as a receiver;
  • Multiple ESP32 boards act as senders. In this example, we’re using two ESP32 sender boards. You can add more boards to your setup if needed.
  • The ESP32 receiver board receives the messages from all senders and identifies which board sent the message.
  • As an example, we’ll exchange BME280 sensor values between the boards. You can modify this project to use any other sensor or exchange any other data between boards.

Parts Required

To follow the project in this tutorial, you’ll need the following parts:

You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!

ESP32: Getting Board MAC Address

To communicate via ESP-NOW, you need to know the MAC address of your boards. To get your board’s MAC Address, you can copy the following code to Thonny IDE and run it on your board.

# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/micropython-esp-now-esp32/
 
import network

wlan = network.WLAN(network.STA_IF)
wlan.active(True)

# Get MAC address (returns bytes)
mac = wlan.config('mac')

# Convert to human-readable format
mac_address = ':'.join('%02x' % b for b in mac)

print("MAC Address:", mac_address)

View raw code

After running the code, it should print the board’s MAC address on the shell.

Thonny IDE - Get ESP32 Board MAC Address - MicroPython

Get the MAC address for all of your boards.

For example, in my case, I get:

  • Sender board 1: 24:0A:C4:31:40:50
  • Sender board 2: 30:AE:A4:F6:7D:4C
  • Receiver board: 30:AE:A4:07:0D:64

Preparing the Sender Boards

For this tutorial, we’ll send data to an ESP32 receiver board (via ESP-NOW) from two different boards. You can modify this project to send data from more boards.

Each board will be identified by an ID (a number that we’ll attribute to each board:

  • ID=1 for board1
  • ID=2 for board2
Two ESP32 Boards with a BME280 Sensor

Wiring the BME280 Sensor

Each sender board will send environmental data from a BME280 sensor. Wire a BME280 sensor to each of your boards. We’ll use the ESP32 default I2C pins.

Learn more about I2C with the ESP32: ESP32 I2C Communication: Set Pins, Multiple Bus Interfaces and Peripherals (Arduino IDE).

ESP32 BME280 Sensor Temperature Humidity Pressure Wiring Diagram Circuit

Not familiar with the BME280 with the ESP32? Read this tutorial: MicroPython: BME280 with ESP32 (Pressure, Temperature, Humidity).

If you’re using a different board model, the default I2C pins might be different.

Importing the BME280 Library

The library to interface with the BME280 sensor is not part of the standard MicroPython library. So, you need to upload the library file to each of your sender boards.

BME280.py MicroPython Module

1. Copy the following code to a new file on Thonny IDE.

from machine import I2C
import time

# BME280 default address.
BME280_I2CADDR = 0x76

# Operating Modes
BME280_OSAMPLE_1 = 1
BME280_OSAMPLE_2 = 2
BME280_OSAMPLE_4 = 3
BME280_OSAMPLE_8 = 4
BME280_OSAMPLE_16 = 5

# BME280 Registers

BME280_REGISTER_DIG_T1 = 0x88  # Trimming parameter registers
BME280_REGISTER_DIG_T2 = 0x8A
BME280_REGISTER_DIG_T3 = 0x8C

BME280_REGISTER_DIG_P1 = 0x8E
BME280_REGISTER_DIG_P2 = 0x90
BME280_REGISTER_DIG_P3 = 0x92
BME280_REGISTER_DIG_P4 = 0x94
BME280_REGISTER_DIG_P5 = 0x96
BME280_REGISTER_DIG_P6 = 0x98
BME280_REGISTER_DIG_P7 = 0x9A
BME280_REGISTER_DIG_P8 = 0x9C
BME280_REGISTER_DIG_P9 = 0x9E

BME280_REGISTER_DIG_H1 = 0xA1
BME280_REGISTER_DIG_H2 = 0xE1
BME280_REGISTER_DIG_H3 = 0xE3
BME280_REGISTER_DIG_H4 = 0xE4
BME280_REGISTER_DIG_H5 = 0xE5
BME280_REGISTER_DIG_H6 = 0xE6
BME280_REGISTER_DIG_H7 = 0xE7

BME280_REGISTER_CHIPID = 0xD0
BME280_REGISTER_VERSION = 0xD1
BME280_REGISTER_SOFTRESET = 0xE0

BME280_REGISTER_CONTROL_HUM = 0xF2
BME280_REGISTER_CONTROL = 0xF4
BME280_REGISTER_CONFIG = 0xF5
BME280_REGISTER_PRESSURE_DATA = 0xF7
BME280_REGISTER_TEMP_DATA = 0xFA
BME280_REGISTER_HUMIDITY_DATA = 0xFD


class Device:
  """Class for communicating with an I2C device.

  Allows reading and writing 8-bit, 16-bit, and byte array values to
  registers on the device."""

  def __init__(self, address, i2c):
    """Create an instance of the I2C device at the specified address using
    the specified I2C interface object."""
    self._address = address
    self._i2c = i2c

  def writeRaw8(self, value):
    """Write an 8-bit value on the bus (without register)."""
    value = value & 0xFF
    self._i2c.writeto(self._address, value)

  def write8(self, register, value):
    """Write an 8-bit value to the specified register."""
    b=bytearray(1)
    b[0]=value & 0xFF
    self._i2c.writeto_mem(self._address, register, b)

  def write16(self, register, value):
    """Write a 16-bit value to the specified register."""
    value = value & 0xFFFF
    b=bytearray(2)
    b[0]= value & 0xFF
    b[1]= (value>>8) & 0xFF
    self.i2c.writeto_mem(self._address, register, value)

  def readRaw8(self):
    """Read an 8-bit value on the bus (without register)."""
    return int.from_bytes(self._i2c.readfrom(self._address, 1),'little') & 0xFF

  def readU8(self, register):
    """Read an unsigned byte from the specified register."""
    return int.from_bytes(
        self._i2c.readfrom_mem(self._address, register, 1),'little') & 0xFF

  def readS8(self, register):
    """Read a signed byte from the specified register."""
    result = self.readU8(register)
    if result > 127:
      result -= 256
    return result

  def readU16(self, register, little_endian=True):
    """Read an unsigned 16-bit value from the specified register, with the
    specified endianness (default little endian, or least significant byte
    first)."""
    result = int.from_bytes(
        self._i2c.readfrom_mem(self._address, register, 2),'little') & 0xFFFF
    if not little_endian:
      result = ((result << 8) & 0xFF00) + (result >> 8)
    return result

  def readS16(self, register, little_endian=True):
    """Read a signed 16-bit value from the specified register, with the
    specified endianness (default little endian, or least significant byte
    first)."""
    result = self.readU16(register, little_endian)
    if result > 32767:
      result -= 65536
    return result

  def readU16LE(self, register):
    """Read an unsigned 16-bit value from the specified register, in little
    endian byte order."""
    return self.readU16(register, little_endian=True)

  def readU16BE(self, register):
    """Read an unsigned 16-bit value from the specified register, in big
    endian byte order."""
    return self.readU16(register, little_endian=False)

  def readS16LE(self, register):
    """Read a signed 16-bit value from the specified register, in little
    endian byte order."""
    return self.readS16(register, little_endian=True)

  def readS16BE(self, register):
    """Read a signed 16-bit value from the specified register, in big
    endian byte order."""
    return self.readS16(register, little_endian=False)


class BME280:
  def __init__(self, mode=BME280_OSAMPLE_1, address=BME280_I2CADDR, i2c=None,
               **kwargs):
    # Check that mode is valid.
    if mode not in [BME280_OSAMPLE_1, BME280_OSAMPLE_2, BME280_OSAMPLE_4,
                    BME280_OSAMPLE_8, BME280_OSAMPLE_16]:
        raise ValueError(
            'Unexpected mode value {0}. Set mode to one of '
            'BME280_ULTRALOWPOWER, BME280_STANDARD, BME280_HIGHRES, or '
            'BME280_ULTRAHIGHRES'.format(mode))
    self._mode = mode
    # Create I2C device.
    if i2c is None:
      raise ValueError('An I2C object is required.')
    self._device = Device(address, i2c)
    # Load calibration values.
    self._load_calibration()
    self._device.write8(BME280_REGISTER_CONTROL, 0x3F)
    self.t_fine = 0

  def _load_calibration(self):

    self.dig_T1 = self._device.readU16LE(BME280_REGISTER_DIG_T1)
    self.dig_T2 = self._device.readS16LE(BME280_REGISTER_DIG_T2)
    self.dig_T3 = self._device.readS16LE(BME280_REGISTER_DIG_T3)

    self.dig_P1 = self._device.readU16LE(BME280_REGISTER_DIG_P1)
    self.dig_P2 = self._device.readS16LE(BME280_REGISTER_DIG_P2)
    self.dig_P3 = self._device.readS16LE(BME280_REGISTER_DIG_P3)
    self.dig_P4 = self._device.readS16LE(BME280_REGISTER_DIG_P4)
    self.dig_P5 = self._device.readS16LE(BME280_REGISTER_DIG_P5)
    self.dig_P6 = self._device.readS16LE(BME280_REGISTER_DIG_P6)
    self.dig_P7 = self._device.readS16LE(BME280_REGISTER_DIG_P7)
    self.dig_P8 = self._device.readS16LE(BME280_REGISTER_DIG_P8)
    self.dig_P9 = self._device.readS16LE(BME280_REGISTER_DIG_P9)

    self.dig_H1 = self._device.readU8(BME280_REGISTER_DIG_H1)
    self.dig_H2 = self._device.readS16LE(BME280_REGISTER_DIG_H2)
    self.dig_H3 = self._device.readU8(BME280_REGISTER_DIG_H3)
    self.dig_H6 = self._device.readS8(BME280_REGISTER_DIG_H7)

    h4 = self._device.readS8(BME280_REGISTER_DIG_H4)
    h4 = (h4 << 24) >> 20
    self.dig_H4 = h4 | (self._device.readU8(BME280_REGISTER_DIG_H5) & 0x0F)

    h5 = self._device.readS8(BME280_REGISTER_DIG_H6)
    h5 = (h5 << 24) >> 20
    self.dig_H5 = h5 | (
        self._device.readU8(BME280_REGISTER_DIG_H5) >> 4 & 0x0F)

  def read_raw_temp(self):
    """Reads the raw (uncompensated) temperature from the sensor."""
    meas = self._mode
    self._device.write8(BME280_REGISTER_CONTROL_HUM, meas)
    meas = self._mode << 5 | self._mode << 2 | 1
    self._device.write8(BME280_REGISTER_CONTROL, meas)
    sleep_time = 1250 + 2300 * (1 << self._mode)

    sleep_time = sleep_time + 2300 * (1 << self._mode) + 575
    sleep_time = sleep_time + 2300 * (1 << self._mode) + 575
    time.sleep_us(sleep_time)  # Wait the required time
    msb = self._device.readU8(BME280_REGISTER_TEMP_DATA)
    lsb = self._device.readU8(BME280_REGISTER_TEMP_DATA + 1)
    xlsb = self._device.readU8(BME280_REGISTER_TEMP_DATA + 2)
    raw = ((msb << 16) | (lsb << 8) | xlsb) >> 4
    return raw

  def read_raw_pressure(self):
    """Reads the raw (uncompensated) pressure level from the sensor."""
    """Assumes that the temperature has already been read """
    """i.e. that enough delay has been provided"""
    msb = self._device.readU8(BME280_REGISTER_PRESSURE_DATA)
    lsb = self._device.readU8(BME280_REGISTER_PRESSURE_DATA + 1)
    xlsb = self._device.readU8(BME280_REGISTER_PRESSURE_DATA + 2)
    raw = ((msb << 16) | (lsb << 8) | xlsb) >> 4
    return raw

  def read_raw_humidity(self):
    """Assumes that the temperature has already been read """
    """i.e. that enough delay has been provided"""
    msb = self._device.readU8(BME280_REGISTER_HUMIDITY_DATA)
    lsb = self._device.readU8(BME280_REGISTER_HUMIDITY_DATA + 1)
    raw = (msb << 8) | lsb
    return raw

  def read_temperature(self):
    """Get the compensated temperature in 0.01 of a degree celsius."""
    adc = self.read_raw_temp()
    var1 = ((adc >> 3) - (self.dig_T1 << 1)) * (self.dig_T2 >> 11)
    var2 = ((
        (((adc >> 4) - self.dig_T1) * ((adc >> 4) - self.dig_T1)) >> 12) *
        self.dig_T3) >> 14
    self.t_fine = var1 + var2
    return (self.t_fine * 5 + 128) >> 8

  def read_pressure(self):
    """Gets the compensated pressure in Pascals."""
    adc = self.read_raw_pressure()
    var1 = self.t_fine - 128000
    var2 = var1 * var1 * self.dig_P6
    var2 = var2 + ((var1 * self.dig_P5) << 17)
    var2 = var2 + (self.dig_P4 << 35)
    var1 = (((var1 * var1 * self.dig_P3) >> 8) +
            ((var1 * self.dig_P2) >> 12))
    var1 = (((1 << 47) + var1) * self.dig_P1) >> 33
    if var1 == 0:
      return 0
    p = 1048576 - adc
    p = (((p << 31) - var2) * 3125) // var1
    var1 = (self.dig_P9 * (p >> 13) * (p >> 13)) >> 25
    var2 = (self.dig_P8 * p) >> 19
    return ((p + var1 + var2) >> 8) + (self.dig_P7 << 4)

  def read_humidity(self):
    adc = self.read_raw_humidity()
    # print 'Raw humidity = {0:d}'.format (adc)
    h = self.t_fine - 76800
    h = (((((adc << 14) - (self.dig_H4 << 20) - (self.dig_H5 * h)) +
         16384) >> 15) * (((((((h * self.dig_H6) >> 10) * (((h *
                          self.dig_H3) >> 11) + 32768)) >> 10) + 2097152) *
                          self.dig_H2 + 8192) >> 14))
    h = h - (((((h >> 15) * (h >> 15)) >> 7) * self.dig_H1) >> 4)
    h = 0 if h < 0 else h
    h = 419430400 if h > 419430400 else h
    return h >> 12

  @property
  def temperature(self):
    "Return the temperature in degrees."
    t = self.read_temperature()
    ti = t // 100
    td = t - ti * 100
    return "{}.{:02d}C".format(ti, td)

  @property
  def pressure(self):
    "Return the temperature in hPa."
    p = self.read_pressure() // 256
    pi = p // 100
    pd = p - pi * 100
    return "{}.{:02d}hPa".format(pi, pd)

  @property
  def humidity(self):
    "Return the humidity in percent."
    h = self.read_humidity()
    hi = h // 1024
    hd = h * 100 // 1024 - hi * 100
    return "{}.{:02d}%".format(hi, hd)

View raw code

2. Go to File > Save as…

Thonny IDE ESP32 ESP8266 MicroPython Save file library to device save as

3. Select save to “MicroPython device“:

Thonny IDE ESP32 ESP8266 MicroPython Save file library to device select

4. Name your file as BME280.py and press the OK button:

BME280 library new MicroPython file Thonny IDE

And that’s it. The library was uploaded to your board.

Repeat this process for all your sender boards.

ESP32 ESP-NOW Sender – MicroPython Script

The following code reads the data from the BME280 sensor and sends it via ESP-NOW in a JSON variable to the ESP32 Receiver board. Don’t forget to modify the code with your receiver board’s MAC address. Additionally, don’t forget to change the board ID for each of your boards.

In this code, we’re using the aioespnow module, which allows us to use ESP-NOW asynchronously. To learn more about asynchronous programming with Micropython, read the following tutorial: MicroPython: ESP32/ESP8266 Asynchronous Programming – Run Multiple Tasks.

# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/micropython-esp-now-esp32-many-to-one/

import network
import aioespnow
import asyncio
import time
import ujson
from machine import Pin, I2C
import BME280

# Board ID
BOARD_ID = 1

# Receiver's MAC address
peer_mac = b'\xff\xff\xff\xff\xff\xff'

# Interval for sending data (in seconds)
send_interval = 10 

# Initialize I2C and BME280
try:
    i2c = I2C(0, scl=Pin(22), sda=Pin(21))  # Adjust pins as needed
    bme = BME280.BME280(i2c=i2c, address=0x76)
except OSError as err:
    print("Failed to initialize BME280:", err)
    raise

# Initialize Wi-Fi in station mode
sta = network.WLAN(network.STA_IF)
try:
    sta.active(True)
    sta.config(channel=1)  # Set channel explicitly
    sta.disconnect()
except OSError as err:
    print("Failed to initialize Wi-Fi:", err)
    raise

# Initialize AIOESPNow
e = aioespnow.AIOESPNow()
try:
    e.active(True)
except OSError as err:
    print("Failed to initialize AIOESPNow:", err)
    raise

# Add peer
try:
    e.add_peer(peer_mac)
except OSError as err:
    print("Failed to add peer:", err)
    raise

# Counter for reading ID
reading_id = 0

def read_temperature():
    try:
        return float(bme.temperature[:-1])  # Remove 'C' from string
    except Exception as err:
        print("Error reading temperature:", err)
        return 0.0

def read_humidity():
    try:
        return float(bme.humidity[:-1])  # Remove '%' from string
    except Exception as err:
        print("Error reading humidity:", err)
        return 0.0

def prepare_sensor_data():
    global reading_id
    data = {
        'id': BOARD_ID,
        'temp': read_temperature(),
        'hum': read_humidity(),
        'readingId': reading_id
    }
    reading_id += 1
    # Serialize to JSON and encode to bytes
    return ujson.dumps(data).encode('utf-8')

async def send_messages(e, peer):
    while True:
        try:
            # Prepare and serialize sensor data
            message = prepare_sensor_data()
            # Send JSON bytes
            if await e.asend(peer, message, sync=True):
                print(f"Sent data: {message.decode('utf-8')}")
            else:
                print("Failed to send data")
        except OSError as err:
            print("Send error:", err)
            await asyncio.sleep(5)
        await asyncio.sleep(send_interval)  # Wait before next send

async def main(e, peer):
    try:
        await send_messages(e, peer)
    except Exception as err:
        print(f"Error in main: {err}")
        await asyncio.sleep(5)
        raise

# Run the async program
try:
    asyncio.run(main(e, peer_mac))
except KeyboardInterrupt:
    print("Stopping sender...")
    e.active(False)
    sta.active(False)

View raw code

How Does the Code Work?

Let’s take a look at how the sender board code works. Alternatively, you can skip to the next section.

Importing Libraries

Start by importing the required libraries to use ESP-NOW, the BME280 sensor, and handle JSON variables.

import network
import aioespnow
import asyncio
import time
import ujson
from machine import Pin, I2C
import BME280

Setting the Board ID

Give a unique ID to your board so that the receiver can easily identify it. Here, we’re just numbering the boards, but you can use a different method, like giving them a name, or simply identifying them by the MAC address (in this case, you don’t need this parameter).

# Board ID
BOARD_ID = 2

Receiver’s MAC Address

Insert the receiver’s MAC address. For example, in my case, the MAC address of the receiver board is 30:AE:A4:07:0D:64. So, I must add ot to the code in bytes like this:

# Receiver's MAC address
peer_mac = b'\x30\xae\xa4\x07\x0d\x64'

Sending Interval

We’ll send new readings via ESP-NOW every 10 seconds. You can adjust that period on the send_interval variable.

# Interval for sending data (in seconds)
send_interval = 10 

Initialize the BME280 Sensor

Initialize an I2C communication on GPIOs 22 and 21. Adjust if you’re using different pins. We also initialize the BME280 sensor.

# Initialize I2C and BME280
try:
    i2c = I2C(0, scl=Pin(22), sda=Pin(21))  # Adjust pins as needed
    bme = BME280.BME280(i2c=i2c, address=0x76)
except OSError as err:
    print("Failed to initialize BME280:", err)
    raise

Initialize Wi-Fi Interface and ESP-NOW

To use ESP-NOW, we need to activate the Wi-Fi interface. In the following lines, we initialize Wi-Fi and ESP-NOW communication protocol using the aioespnow module.

# Initialize Wi-Fi in station mode
sta = network.WLAN(network.STA_IF)
try:
    sta.active(True)
    sta.config(channel=1)  # Set channel explicitly
    sta.disconnect()
except OSError as err:
    print("Failed to initialize Wi-Fi:", err)
    raise

# Initialize AIOESPNow
e = aioespnow.AIOESPNow()
try:
    e.active(True)
except OSError as err:
    print("Failed to initialize AIOESPNow:", err)
    raise

Add Peers

In the next line, we add the receiver board as a peer. The e.add_peer(peer_mac) function is needed for reliable unicast communication in aioespnow. It registers the receiver’s MAC address to ensure the sender can send messages to that specific board.

# Add peer
try:
    e.add_peer(peer_mac)
except OSError as err:
    print("Failed to add peer:", err)
    raise

Getting BME280 Sensor Data

The following functions return the temperature and humidity from the BME280 sensor.

def read_temperature():
    try:
        return float(bme.temperature[:-1])  # Remove 'C' from string
    except Exception as err:
        print("Error reading temperature:", err)
        return 0.0

def read_humidity():
    try:
        return float(bme.humidity[:-1])  # Remove '%' from string
    except Exception as err:
        print("Error reading humidity:", err)
        return 0.0

The functions from the BME280 library return the results with the unit character, that’s why we’re removing characters at the end of the readings.

Learn more about interfacing the BME280 with the ESP32 using MicroPython: MicroPython: BME280 with ESP32 (Pressure, Temperature, Humidity).

Adding the Data to a JSON Variable

We create a function called prepare_sensor_data() that will return a JSON variable containing the data we want to send. In this case, we’re sending the board ID, the temperature, the humidity, and the reading ID (just a number to track how many readings were taken since the program started running).

def prepare_sensor_data():
    global reading_id
    data = {
        'id': BOARD_ID,
        'temp': read_temperature(),
        'hum': read_humidity(),
        'readingId': reading_id
    }
    reading_id += 1
    # Serialize to JSON and encode to bytes
    return ujson.dumps(data).encode('utf-8')

Sending Messages via ESP-NOW

Then, we create an asynchronous function to send the ESP-NOW messages in an asynchronous way.

async def send_messages(e, peer):
    while True:
        try:
            # Prepare and serialize sensor data
            message = prepare_sensor_data()
            # Send JSON bytes
            if await e.asend(peer, message, sync=True):
                print(f"Sent data: {message.decode('utf-8')}")
            else:
                print("Failed to send data")
        except OSError as err:
            print("Send error:", err)
            await asyncio.sleep(5)
        await asyncio.sleep(send_interval)  # Wait before next send

Running the Asynchronous Tasks

Then, the following function runs the send_messages task to send data via ESP-NOW.

async def main(e, peer):
    try:
        await send_messages(e, peer)
    except Exception as err:
        print(f"Error in main: {err}")
        await asyncio.sleep(5)
        raise

Finally, we start the asynchronous program.

# Run the async program
try:
    asyncio.run(main(e, peer_mac))
except KeyboardInterrupt:
    print("Stopping sender...")
    e.active(False)
    sta.active(False)

Running and Uploading the Code to Your Boards

After copying the code to Thonny IDE and making the required changes, test the code by running it on Thonny IDE.

After establishing a connection with your board, you can click on the green run button.

Thonny IDE Green Run Button

You should get something similar, as shown in the picture below, in your MicroPython shell.

ESP32 Sender ESP-NOW - Failed to Send

At the moment, the delivery will fail because we haven’t set the receiver board yet.

Uploading the Code to Your Boards

Important note: just running the file with Thonny doesn’t copy it permanently to the board’s filesystem. This means that if you unplug it from your computer and apply power to the board, nothing will happen because it doesn’t have any Python file saved on its filesystem. The Thonny IDE Run function is useful to test the code, but if you want to upload it permanently to your board, you need to create and save a file to the board’s filesystem.

To run the code on your boards without being connected to your computer, you must upload it to the board’s filesystem with the name main.py.

Go to File > Save as... MicroPython Device.

Thonny IDE ESP32 ESP8266 MicroPython Save file library to device select

Call that file main.py and save it on the board.

Uploading main MicroPython file to ESP32 board using Thonny IDE

Now, if you restart your board, it will start running the code. You may not be able to access the MicroPython Shell afterwards.

Preparing the Receiver Board

The ESP32 receiver board will receive the data from the different sender boards and will display it on the OLED display. In this example, we’re receiving data from two different boards

ESP-NOW with ESP32 MicroPython: setup with one receiver and two sender boards

Wiring the OLED Display

Wire the OLED display to your ESP32 board. We’re using the default I2C pins—GPIO 22 (SCL) and GPIO21 (SDA). Adjust if you’re using an ESP32 model with a different pinout.

Esp32 with OLED Display Wiring Diagram

Importing the SSD1306 Library

The library to interface with the OLED sensor is not part of the standard MicroPython library. So, you need to upload the library file to your receiver board.

SSD1306.py MicroPython Module

1. Copy the following code to a new file on Thonny IDE.

# MicroPython SSD1306 OLED driver, I2C and SPI interfaces created by Adafruit

import time
import framebuf

# register definitions
SET_CONTRAST        = const(0x81)
SET_ENTIRE_ON       = const(0xa4)
SET_NORM_INV        = const(0xa6)
SET_DISP            = const(0xae)
SET_MEM_ADDR        = const(0x20)
SET_COL_ADDR        = const(0x21)
SET_PAGE_ADDR       = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP       = const(0xa0)
SET_MUX_RATIO       = const(0xa8)
SET_COM_OUT_DIR     = const(0xc0)
SET_DISP_OFFSET     = const(0xd3)
SET_COM_PIN_CFG     = const(0xda)
SET_DISP_CLK_DIV    = const(0xd5)
SET_PRECHARGE       = const(0xd9)
SET_VCOM_DESEL      = const(0xdb)
SET_CHARGE_PUMP     = const(0x8d)


class SSD1306:
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        # Note the subclass must initialize self.framebuf to a framebuffer.
        # This is necessary because the underlying data buffer is different
        # between I2C and SPI implementations (I2C needs an extra byte).
        self.poweron()
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP | 0x00, # off
            # address setting
            SET_MEM_ADDR, 0x00, # horizontal
            # resolution and layout
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
            SET_MUX_RATIO, self.height - 1,
            SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
            SET_DISP_OFFSET, 0x00,
            SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV, 0x80,
            SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1,
            SET_VCOM_DESEL, 0x30, # 0.83*Vcc
            # display
            SET_CONTRAST, 0xff, # maximum
            SET_ENTIRE_ON, # output follows RAM contents
            SET_NORM_INV, # not inverted
            # charge pump
            SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01): # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP | 0x00)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width == 64:
            # displays with width of 64 pixels are shifted by 32
            x0 += 32
            x1 += 32
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_framebuf()

    def fill(self, col):
        self.framebuf.fill(col)

    def pixel(self, x, y, col):
        self.framebuf.pixel(x, y, col)

    def scroll(self, dx, dy):
        self.framebuf.scroll(dx, dy)

    def text(self, string, x, y, col=1):
        self.framebuf.text(string, x, y, col)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        # Add an extra byte to the data buffer to hold an I2C data/command byte
        # to use hardware-compatible I2C transactions.  A memoryview of the
        # buffer is used to mask this byte from the framebuffer operations
        # (without a major memory hit as memoryview doesn't copy to a separate
        # buffer).
        self.buffer = bytearray(((height // 8) * width) + 1)
        self.buffer[0] = 0x40  # Set first byte of data buffer to Co=0, D/C=1
        self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80 # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_framebuf(self):
        # Blast out the frame buffer using a single I2C transaction to support
        # hardware I2C interfaces.
        self.i2c.writeto(self.addr, self.buffer)

    def poweron(self):
        pass


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        self.buffer = bytearray((height // 8) * width)
        self.framebuf = framebuf.FrameBuffer1(self.buffer, width, height)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.high()
        self.dc.low()
        self.cs.low()
        self.spi.write(bytearray([cmd]))
        self.cs.high()

    def write_framebuf(self):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.high()
        self.dc.high()
        self.cs.low()
        self.spi.write(self.buffer)
        self.cs.high()

    def poweron(self):
        self.res.high()
        time.sleep_ms(1)
        self.res.low()
        time.sleep_ms(10)
        self.res.high()

View raw code

2. Go to File Save as and select MicroPython device.

Thonny IDE ESP32 ESP8266 MicroPython Save file library to device select

3. Name the file ssd1306.py and click OK to save the file on the ESP Filesystem.

And that’s it. The library was uploaded to your board. Now, you can use the library functionalities in your code by importing the library.

ESP-NOW Receiver Board – MicroPython Script

The following code will set up your ESP32 board as an ESP-NOW receiver to get data from the other two sender boards. After receiving the data, it will be displayed on the OLED screen.

Don’t forget to modify the code to add your sender boards’ MAC addresses.

# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/micropython-esp-now-esp32-many-to-one/

import network
import aioespnow
import asyncio
import ujson
from machine import Pin, I2C
import ssd1306

# Initialize I2C for OLED
try:
    i2c = I2C(0, scl=Pin(22), sda=Pin(21))  # Adjust pins as needed
    display = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
    print("SSD1306 initialized")
except Exception as err:
    print("Failed to initialize SSD1306:", err)
    raise

# Initialize Wi-Fi in station mode
sta = network.WLAN(network.STA_IF)
try:
    sta.active(True)
    sta.config(channel=1)  # Set channel explicitly
    sta.disconnect()
except OSError as err:
    print("Failed to initialize Wi-Fi:", err)
    raise

# Initialize AIOESPNow
e = aioespnow.AIOESPNow()
try:
    e.active(True)
except OSError as err:
    print("Failed to initialize AIOESPNow:", err)
    raise

# Sender's MAC addresses (replace with actual sender MACs)
sender_mac_1 = b'\x24\x0a\xc4\x31\x40\x50'  # First sender's MAC (Board ID=1)
sender_mac_2 = b'\x30\xae\xa4\xf6\x7d\x4c'  # Second sender's MAC (Board ID=2)

# Add peers
try:
    e.add_peer(sender_mac_1)
except OSError as err:
    print(f"Failed to add peer {sender_mac_1.hex()}:", err)
    raise

try:
    e.add_peer(sender_mac_2)
except OSError as err:
    print(f"Failed to add peer {sender_mac_2.hex()}:", err)
    raise

# Dictionary to store latest readings for each board
board_readings = {
    1: {'temp': 0.0, 'hum': 0.0, 'readingId': 0},
    2: {'temp': 0.0, 'hum': 0.0, 'readingId': 0}
}

# Update OLED display with temperature and humidity for both boards on separate lines.
def update_display():
    try:
        display.fill(0)
        # Board 1 data
        display.text("Board 1:", 0, 0)
        display.text(f"Temp: {board_readings[1]['temp']:.1f} C", 0, 10)
        display.text(f"Hum: {board_readings[1]['hum']:.1f} %", 0, 20)
        # Board 2 data
        display.text("Board 2:", 0, 32)
        display.text(f"Temp: {board_readings[2]['temp']:.1f} C", 0, 42)
        display.text(f"Hum: {board_readings[2]['hum']:.1f} %", 0, 52)
        display.show()
        print("Display updated")
    except Exception as err:
        print("Error updating display:", err)

# Async function to receive and process messages.
async def receive_messages(e):
    print("Listening for ESP-NOW messages...")
    while True:
        try:
            async for mac, msg in e:
                try:
                    # Decode and parse JSON message
                    json_str = msg.decode('utf-8')
                    data = ujson.loads(json_str)
                    
                    # Extract parameters
                    board_id = data['id']
                    temperature = data['temp']
                    humidity = data['hum']
                    reading_id = data['readingId']
                    
                    # Store in board_readings dictionary
                    if board_id in (1, 2):
                        board_readings[board_id] = {
                            'temp': temperature,
                            'hum': humidity,
                            'readingId': reading_id
                        }
                        # Update OLED display
                        update_display()
                    
                    # Display on MicroPython terminal
                    print(f"\nReceived from {mac.hex()}:")
                    print(f"  Board ID: {board_id}")
                    print(f"  Temperature: {temperature} C")
                    print(f"  Humidity: {humidity} %")
                    print(f"  Reading ID: {reading_id}")
                except (ValueError, KeyError) as err:
                    print(f"Error parsing JSON: {err}")
        except OSError as err:
            print("Receive error:", err)
            await asyncio.sleep(5)

async def main(e):
    await receive_messages(e)

# Run the async program
try:
    asyncio.run(main(e))
except KeyboardInterrupt:
    print("Stopping receiver...")
    e.active(False)
    sta.active(False)

View raw code

How Does the Code Work?

Let’s take a look at how the sender board code works. Alternatively, you can skip to the next section.

Importing Libraries

Start by importing the required libraries (make sure you uploaded the ssd1306.py file previously to add support for interfacing with the display).

import network
import aioespnow
import asyncio
import ujson
from machine import Pin, I2C
import ssd1306

Initializing the OLED Display

Initialize I2C communication and the OLED display with the following lines.

# Initialize I2C for OLED
try:
    i2c = I2C(0, scl=Pin(22), sda=Pin(21))  # Adjust pins as needed
    display = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
    print("SSD1306 initialized")
except Exception as err:
    print("Failed to initialize SSD1306:", err)
    raise

Initializing the WiFi Interface and ESP-NOW

Initialize the WiFi interface and ESP-NOW.

# Initialize Wi-Fi in station mode
sta = network.WLAN(network.STA_IF)
try:
    sta.active(True)
    sta.config(channel=1)  # Set channel explicitly
    sta.disconnect()
except OSError as err:
    print("Failed to initialize Wi-Fi:", err)
    raise

# Initialize AIOESPNow
e = aioespnow.AIOESPNow()
try:
    e.active(True)
except OSError as err:
    print("Failed to initialize AIOESPNow:", err)
    raise

Adding the Sender Boards as Peers

Add the the sender boards as peers (it is not mandatory to add the sender boards as peers on the receiver side, but it guarantees more reliability in the communication).

Insert the sender boards’ MAC addresses in the following variables (make sure to insert the MAC address in bytes format).

# Sender's MAC addresses (replace with actual sender MACs)
sender_mac_1 = b'\x24\x0a\xc4\x31\x40\x50'  # First sender's MAC (Board ID=1)
sender_mac_2 = b'\x30\xae\xa4\xf6\x7d\x4c'  # Second sender's MAC (Board ID=2)

Then, add them as ESP-NOW peers:

# Add peers
try:
    e.add_peer(sender_mac_1)
except OSError as err:
    print(f"Failed to add peer {sender_mac_1.hex()}:", err)
    raise

try:
    e.add_peer(sender_mac_2)
except OSError as err:
    print(f"Failed to add peer {sender_mac_2.hex()}:", err)
    raise

Dictionary to Save the Readings

Create a dictionary to store the latest received sensor readings from each board. Saving this information in a dictionary is a great way to save all the data received in one single variable.

board_readings = {
    1: {'temp': 0.0, 'hum': 0.0, 'readingId': 0},
    2: {'temp': 0.0, 'hum': 0.0, 'readingId': 0}
}

Updating the Display

The update_display() function gets the data received from each board, that is already stored in the board_readings variable, and displays it on the OLED screen.

def update_display():
    try:
        display.fill(0)
        # Board 1 data
        display.text("Board 1:", 0, 0)
        display.text(f"Temp: {board_readings[1]['temp']:.1f} C", 0, 10)
        display.text(f"Hum: {board_readings[1]['hum']:.1f} %", 0, 20)
        # Board 2 data
        display.text("Board 2:", 0, 32)
        display.text(f"Temp: {board_readings[2]['temp']:.1f} C", 0, 42)
        display.text(f"Hum: {board_readings[2]['hum']:.1f} %", 0, 52)
        display.show()
        print("Display updated")
    except Exception as err:
        print("Error updating display:", err)

To learn more about interfacing the OLED with the ESP32 using MicroPython, you can check our tutorial: MicroPython: OLED Display with ESP32 and ESP8266.

Receiving ESP-NOW Messages

We create an asynchronous function to get and handle the messages received via ESP-NOW.

async def receive_messages(e):
    print("Listening for ESP-NOW messages...")
    while True:
        try:
            async for mac, msg in e:

The data comes in JSON format. We first decode and parse the JSON message.

try:
    # Decode and parse JSON message
    json_str = msg.decode('utf-8')
    data = ujson.loads(json_str)

We extract each of the parameters into individual variables.

# Extract parameters into individual variables
board_id = data['id']
temperature = data['temp']
humidity = data['hum']
reading_id = data['readingId']

Finally, we save the data in the right place in our board_readings dictionary according to the board’s ID.

# Store in board_readings dictionary
if board_id in (1, 2):
    board_readings[board_id] = {
    'temp': temperature,
    'hum': humidity,
    'readingId': reading_id
}

After that, we have the board_readings variable updated, so now we can call the updated_display() function to update the display with the current information.

update_display()

Finally, we print the received data in the MicroPython shell.

# Display on serial monitor
print(f"\nReceived from {mac.hex()}:")
print(f"  Board ID: {board_id}")
print(f"  Temperature: {temperature} C")
print(f"  Humidity: {humidity} %")
print(f"  Reading ID: {reading_id}")

Running the Asynchronous Tasks

Then, the following function runs the receive_messages task to send data via ESP-NOW.

async def main(e):
    await receive_messages(e)

Finally, we start the asynchronous program.

# Run the async program
try:
    asyncio.run(main(e, peer_mac))
except KeyboardInterrupt:
    print("Stopping sender...")
    e.active(False)
    sta.active(False)

Demonstration

Upload and/or run the previous code on your receiver board. If you’re connected to Thonny IDE Terminal, it will print the received data.

At the same time, it will print the readings received from each of the sender boards on the OLED screen.

ESP-NOW Receiver Displaying Data from Multiple ESP32 boards on OLED screen

Wrapping Up

In this tutorial, you learned how to send data from multiple ESP32 senders to one ESP32 receiver board via ESP-NOW using MicroPython. We also showed you how to send multiple data variables in JSON format and how to parse them and handle them to get the individual bits of information that we want.

The project in this tutorial can be easily adapted to add more sender boards and send any other information you want.

We hope you’ve found this guide useful. We have more ESP-NOW guides with MicroPython that you may find useful:

Finally, if you want to learn more about MicroPython, don’t forget to check out our resources:



Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »
Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »

Recommended Resources

Build a Home Automation System from Scratch » With Raspberry Pi, ESP8266, Arduino, and Node-RED.

Home Automation using ESP8266 eBook and video course » Build IoT and home automation projects.

Arduino Step-by-Step Projects » Build 25 Arduino projects with our course, even with no prior experience!

What to Read Next…


Enjoyed this project? Stay updated by subscribing our newsletter!

Leave a Comment

Download Our Free eBooks and Resources

Get instant access to our FREE eBooks, Resources, and Exclusive Electronics Projects by entering your email address below.