Wireless ESP-NOW communication between the custom ESP32-S3 board (transmitter) and a Heltec LoRa V3 (receiver), streaming live weather data for Kigali — temperature, humidity, location, and weather state — fetched from the OpenWeatherMap API and sent peer to peer over 2.4 GHz, addressed by MAC.

Networking week moves the microcontroller beyond its own sensors, making boards talk to each other. For this week I set up a wireless ESP-NOW link between my custom ESP32-S3 board and a Heltec LoRa V3 (which is also ESP32 based). The ESP32-S3 acts as the transmitter, connecting to Wi-Fi to fetch live weather data for Kigali, Rwanda from the OpenWeatherMap API — temperature, humidity, location name, and weather state (Sunny, Cloudy, Rainy, etc.) — then forwarding that data wirelessly to the Heltec receiver over ESP-NOW.
ESP-NOW is Espressif's connectionless wireless protocol with no router, no IP stack, and no pairing handshake. Every device is addressed purely by its factory-burned MAC address. The workflow had three clear milestones: reading the Heltec receiver MAC address to use as the peer address, programming the ESP32-S3 transmitter to fetch and forward weather data, then programming the Heltec receiver and watching the live Kigali weather arrive wirelessly.
Group Assignment
Send a message between two projects. See the group page →
As a group we sent a message between two student projects. We chose the I2C bus as the protocol: one board acted as the controller and the other as a peripheral on a shared two wire bus, each with its own bus address. The controller wrote a short message to the peripheral address, the peripheral read it back from its buffer, and we confirmed on the Serial Monitor that the exact bytes sent arrived intact on the other side. That proved the data arrived and was not corrupted on the wire. The full write up lives on the group page linked above.
Individual Assignment
Design, build, and connect a wired or wireless node with network or bus addresses and local input and/or output device(s).
Individually, I used the custom ESP32-S3 board as a wireless weather transmitter node and the Heltec LoRa V3 as the receiver node, each uniquely identified by its hardware MAC address on the ESP-NOW network. Live weather data for Kigali is fetched from the OpenWeatherMap API and forwarded wirelessly to the receiver.
ESP-NOW is a connectionless protocol by Espressif that runs on the ESP32's built-in 2.4 GHz Wi-Fi radio without needing a router, access point, or IP address for the wireless link itself. Devices address each other directly by their 48-bit hardware MAC address.
The approach this week combines two wireless technologies: the ESP32-S3 first connects to Wi-Fi to pull live weather data from OpenWeatherMap, then uses ESP-NOW to forward that data wirelessly to the Heltec — no wires, no shared router needed between the two boards.

WiFi.macAddress() and prints it to Serial. This value is essential: it must be hardcoded byte by byte into the transmitter as the ESP-NOW peer address. Getting it right before writing any radio code avoids a silent link failure later.#include <WiFi.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA); // required to read the MAC
Serial.print("Receiver MAC Address: ");
Serial.println(WiFi.macAddress());
}
void loop() {}Result: The Serial Monitor printed the Heltec MAC in the format XX:XX:XX:XX:XX:XX. This value was noted and converted to the byte array used in the transmitter's receiverMAC[].

Current Weather Data endpoint — all we need for this project.Key format: the key looks like dc1d744dcedcfc32de5d6f15c8d0f1b0 — paste it directly into the apiKey variable in the transmitter sketch.
YOUR_KEY with your actual key). A JSON response confirms the key is active and the city name is recognised.https://api.openweathermap.org/data/2.5/weather?q=Kigali,rw&units=metric&appid=YOUR_KEYExpected response: a JSON object with "main": { "temp": ..., "humidity": ... } and a "weather" array containing the condition string (e.g. "Clouds"). If you see {"cod":401} the key is not yet active — wait a few minutes and retry.
{
"main": {
"temp": 19.4,
"humidity": 82
},
"weather": [
{ "main": "Clouds", "description": "broken clouds" }
],
"name": "Kigali"
}apiKey variable. The city and country are already set to Kigali and rw — no other API configuration changes are needed.// OpenWeatherMap API
String apiKey = "dc1d744dcedcfc32de5d6f15c8d0f1b0"; // ← your key here
String city = "Kigali";
String country = "rw";Keep your key private: avoid committing it to a public repository. For a class project sharing the sketch is fine, but for any production use store the key outside the source file.
struct_message with three fields: float temp for temperature in °C, int humidity for relative humidity percentage, and char state[20] for the weather label. The state is derived by a getState() helper that maps the raw API string to one of Sunny, Cloudy, Rain, or Unknown. The identical struct must be declared in both the transmitter and receiver sketches so both sides interpret the raw bytes the same way.// Shared struct — identical on TX and RX sides
typedef struct struct_message {
float temp; // °C from OpenWeatherMap
int humidity; // % relative humidity
char state[20]; // "Sunny", "Cloudy", "Rain", "Unknown"
} struct_message;
struct_message weatherData;esp_now_send(). A send callback confirms delivery for each packet. The Wi-Fi connection is only used for the HTTP fetch — the ESP-NOW link to the Heltec runs independently over the same radio.#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <esp_now.h>
#include <esp_wifi.h>
// WiFi (for internet only)
const char* ssid = "STARLINK";
const char* password = "InnovationS";
// OpenWeatherMap API
String apiKey = "dc1d744dcedcfc32de5d6f15c8d0f1b0";
String city = "Kigali";
String country = "rw";
// YOUR RECEIVER MAC ADDRESS
uint8_t receiverMac[] = {0x64, 0xE8, 0x33, 0x69, 0x69, 0x14};
// Data structure
typedef struct struct_message {
float temp;
int humidity;
char state[20];
} struct_message;
struct_message weatherData;
esp_now_peer_info_t peerInfo;
// Convert weather → simple state
String getState(String w) {
w.toLowerCase();
if (w.indexOf("rain") >= 0) return "Rain";
if (w.indexOf("cloud") >= 0) return "Cloudy";
if (w.indexOf("clear") >= 0) return "Sunny";
return "Unknown";
}
void setup() {
Serial.begin(115200);
// Connect WiFi (for API only)
WiFi.begin(ssid, password);
Serial.print("Connecting WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi Connected!");
// ESP-NOW init
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW Init Failed");
return;
}
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");
return;
}
Serial.println("ESP-NOW Ready");
}
void loop() {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
String url = "https://api.openweathermap.org/data/2.5/weather?q=" +
city + "," + country +
"&units=metric&appid=" + apiKey;
http.begin(url);
int httpCode = http.GET();
if (httpCode > 0) {
String payload = http.getString();
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
float temp = doc["main"]["temp"];
int hum = doc["main"]["humidity"];
String weather = doc["weather"][0]["main"];
weatherData.temp = temp;
weatherData.humidity = hum;
String state = getState(weather);
state.toCharArray(weatherData.state, 20);
// Send via ESP-NOW
esp_now_send(receiverMac, (uint8_t *)&weatherData, sizeof(weatherData));
Serial.println("Sent:");
Serial.print("Temp: "); Serial.println(temp);
Serial.print("Hum: "); Serial.println(hum);
Serial.print("State: "); Serial.println(state);
Serial.println("------------------");
}
}
http.end();
}
delay(60000); // update every 60 seconds
}Note: The struct on this side uses float temp, int humidity, and char state[20] — the receiver must declare the identical layout or the bytes will be misread. The getState() helper maps the raw API weather string to a simple label: Rain, Cloudy, Sunny, or Unknown.

onReceive callback. Whenever a packet arrives, the callback copies the raw bytes into the struct and calls updateUI(), which clears only the value area (right side of the display) and redraws the live values without flickering the static labels. The Serial Monitor also logs each received packet for debugging.#include <esp_now.h>
#include <WiFi.h>
#include <Wire.h>
#include <U8g2lib.h>
// OLED setup (your pins)
U8G2_SSD1306_128X64_NONAME_F_SW_I2C u8g2(
U8G2_R0,
/* clock=*/ 18,
/* data=*/ 17,
/* reset=*/ 21
);
// Must match sender exactly
typedef struct struct_message {
float temp;
int humidity;
char state[20];
} struct_message;
struct_message incomingData;
// ---------------- UI DRAW ----------------
void drawUI() {
u8g2.clearBuffer();
// ===== HEADER BOX =====
u8g2.drawFrame(0, 0, 128, 16);
u8g2.setFont(u8g2_font_6x10_tf);
const char* title = "WEATHER";
int titleW = u8g2.getStrWidth(title);
u8g2.drawStr((128 - titleW) / 2, 12, title);
// ===== CITY (CENTERED) =====
u8g2.setFont(u8g2_font_5x7_tf);
const char* city = "KIGALI";
int cityW = u8g2.getStrWidth(city);
u8g2.drawStr((128 - cityW) / 2, 28, city);
// separator
u8g2.drawHLine(0, 32, 128);
// labels
u8g2.drawStr(5, 42, "Temp:");
u8g2.drawStr(5, 52, "Hum:");
u8g2.drawStr(5, 62, "State:");
u8g2.sendBuffer();
}
// ---------------- UPDATE VALUES ----------------
void updateUI() {
char tempBuf[12];
char humBuf[12];
sprintf(tempBuf, "%.1fC", incomingData.temp);
sprintf(humBuf, "%d%%", incomingData.humidity);
// clear value area
u8g2.setDrawColor(0);
u8g2.drawBox(60, 34, 68, 30);
u8g2.setDrawColor(1);
// values
u8g2.setFont(u8g2_font_5x7_tf);
u8g2.drawStr(60, 42, tempBuf);
u8g2.drawStr(60, 52, humBuf);
u8g2.drawStr(60, 62, incomingData.state);
u8g2.sendBuffer();
}
// ---------------- ESP-NOW RECEIVE ----------------
void onReceive(const uint8_t *mac, const uint8_t *incoming, int len) {
memcpy(&incomingData, incoming, sizeof(incomingData));
Serial.println("===== RECEIVED =====");
Serial.print("Temp: "); Serial.println(incomingData.temp);
Serial.print("Hum: "); Serial.println(incomingData.humidity);
Serial.print("State: "); Serial.println(incomingData.state);
updateUI();
}
// ---------------- SETUP ----------------
void setup() {
Serial.begin(115200);
u8g2.begin();
drawUI();
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW Init Failed");
return;
}
esp_now_register_recv_cb(onReceive);
Serial.println("Receiver Ready");
}
// ---------------- LOOP ----------------
void loop() {
// nothing needed
}Link established: The Heltec OLED displayed live Kigali weather — temperature, humidity, and weather state — updating every 60 seconds as the ESP32-S3 pulled fresh data from OpenWeatherMap and forwarded it wirelessly over ESP-NOW. The Serial Monitor confirmed each received packet alongside the display update.



Getting the full chain working — from API fetch to wireless delivery — brought up a few issues. These are the ones I ran into and how I fixed each one.
receiverMAC[]. ESP-NOW throws no error for a wrong peer address, it just sends into nothing. I read the Heltec MAC again, fixed the byte, and packets started arriving right away.WIFI_STA mode, made delivery stable. The transmitter's Wi-Fi connection for the API fetch and the ESP-NOW link share the same radio without conflict.state field on the receiver printing as random characters. The cause was not null-terminating the char array after strncpy. Added myData.state[sizeof(myData.state)-1] = '\0' as a safety guard and the string arrived clean on the Heltec every time after that.This week connected a real external data source — the internet — into a peer-to-peer wireless system with a physical output. The ESP32-S3 became a bridge between the cloud and the local ESP-NOW network: pulling live weather data for Kigali from OpenWeatherMap over Wi-Fi, then forwarding it wirelessly to the Heltec receiver, where a U8g2-driven OLED display renders the temperature, humidity, and weather state in a clean two-panel layout — static labels on the left, live values on the right. That full chain, from an internet API to pixels on a screen with no wires between the two boards, is a pattern that appears across a huge range of real embedded systems.
Reading the Heltec MAC address first was a necessary first step: ESP-NOW is a purely MAC address based protocol with no discovery, no IP, and no DNS. You must know the receiver's exact 6 byte address before writing a single line of transmitter code. It makes the concept of a network address very concrete and tangible.
Using a struct with mixed types — floats and char arrays — also highlighted how important it is that both sides of the link declare the struct identically. A mismatch in field order or size would silently misalign the data on arrival. The OnDataSent and OnDataRecv callbacks together give a closed-loop confirmation of delivery without any extra test equipment.