This week I designed a new PCB for my final project, programmed the ENS160 + AHT21 air quality sensor via I2C, explored UART board-to-board communication, and implemented a full MQTT over Wi-Fi pipeline that publishes sensor data to a live web dashboard in real time.
Design, build, and connect wired or wireless nodes with local input and output components.
Send a message between two projects and demonstrate a networking protocol in action.
For this week and for my final project I needed a PCB with a XIAO ESP32-C6 microcontroller, so I started by designing this new board in KiCad. I followed the same steps from Week 6 — Electronics Design. This time I designed the board already taking my final project into account, so I began by thinking through which components I would need.
I built on the versatility of my previous board by adding pin headers for future attachments, since my idea is to connect the sensor module through pogo pins, but if those don't work as expected, I can use the pin headers. Once I had a general idea I built the schematic in KiCad and routed the traces in the PCB design view, following the same workflow as Week 6.
I followed the same export process from Week 8 — Electronics Production: exported the PCB as Gerber from KiCad and converted it to PNG. However, this week I tried something new and made my PCB with the XTool laser. Below are the steps to reproduce it.
After cutting, I soldered the components. Since I started with the communications section and the pogo pins had not arrived yet, I soldered male pin headers provisionally in their place.
This week I started programming my new ENS160 + AHT21 sensor. I used Gemini as my teacher again, specifying in the prompt that I did not want it to write the code or solutions for me. I reviewed the theory and wrote the code myself to test and understand it.
The ENS160 is a MOX-type air quality sensor. It uses an internal resistor that heats up to trigger a chemical reaction with the air, estimating CO₂ levels based on that. Because of this, during the first 3–5 minutes of operation (warm-up), it will output garbage values.
Because the sensor changes its behavior with heat, it comes paired with the AHT21, which measures temperature and humidity to help calibrate the gas sensor.
This was my first written code. It had a lot of mistakes. I wrote some of them down together with the AI corrections — by questioning the errors and understanding them, I moved to the next version.
#include <wire.h> // wrong: should be Wire.h (capital W)
#include <ScioSense_ENS160.h>
ScioSense_ENS160.h SensorA; // wrong: don't include .h here
float SensorA = D6; // wrong: I2C sensors don't get pin assignments
void setup () {
Serial.begin(11520); // wrong: should be 115200
SensorA.begin();
}
void loop () {
SensorA.geteCO2() // wrong: must be stored in a variable
}
After correcting the mistakes, I wrote this version with my own inline notes:
#include <Wire.h>
#include <ScioSense_ENS160.h>
ScioSense_ENS160 SensorA;
void setup () {
Serial.begin(115200);
SensorA.begin();
}
void loop () {
// geteCO2 comes from the library — "get" retrieves data.
// Open the variable (box) first, then store the reading inside it.
int valorCO2 = SensorA.geteCO2();
Serial.println(valorCO2); // print data to serial monitor
delay(1000); // wait 1 second before the next reading
}
Since my sensor module includes both sensors, I needed to read the AHT21 first, pass the temperature and humidity to the ENS160, and then get a calibrated gas reading. I found a reference on Instructables — ENS160-AHT21 Sensor for Arduino — and looked only at the libraries used (ScioSense_ENS160.h and Adafruit_AHTX0.h) before writing my own version.
My main error was the loop order — I was reading CO₂ first and humidity second, but it must be the other way around. This is the corrected code:
#include <Wire.h>
#include <ScioSense_ENS160.h>
#include <Adafruit_AHTX0.h>
ScioSense_ENS160 SensorA;
Adafruit_AHTX0 ahtSensor;
void setup () {
Serial.begin(115200);
ahtSensor.begin();
SensorA.begin();
}
void loop () {
float valorTemperature = ahtSensor.readTemperature();
float valorHumidity = ahtSensor.readHumidity(); // must be read BEFORE CO2
// "set" is like "get" but is used to configure/send values
SensorA.setTemperatureandHumidity(valorTemp, valorHum);
// store the compensated CO2 reading
int valorCO2 = SensorA.geteCO2();
Serial.println(valorCO2);
delay(1000);
}
SDA Sensor → D4 (ESP32-C6)SCL Sensor → D5 (ESP32-C6)VCC Sensor → VCC (ESP32-C6)GND Sensor → GND (ESP32-C6)One of my first problems was that the serial monitor was not enabled and nothing appeared no matter what I tried. I uploaded this simple test code to verify it worked — at first nothing came out, but pressing the BOOT button made the message appear.
void setup() {
Serial.begin(115200);
delay(100);
Serial.println("HELLO");
}
void loop() {
Serial.println("alive");
delay(1000);
}
After fixing the serial monitor, I could connect to the XIAO but still got no sensor data. The issue was the wrong I2C address. I uploaded this scanner code to find which address the device was actually using:
#include <Wire.h>
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println("Scanning I2C...");
Wire.begin(D4, D5);
for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
byte error = Wire.endTransmission();
if (error == 0) {
Serial.print("Device found at: 0x");
Serial.println(addr, HEX);
}
}
Serial.println("Scan complete.");
}
void loop() {}
The scan returned address 0x53, but the library was looking for the sensor at 0x52 by default. I explicitly declared the correct address in the object constructor and the sensor started working.
#include <Wire.h>
#include <ScioSense_ENS160.h>
#include <Adafruit_AHTX0.h>
ScioSense_ENS160 SensorA(0x53); // <-- correct I2C address
Adafruit_AHTX0 ahtSensor;
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println("Starting...");
Wire.begin(D4, D5);
if (!ahtSensor.begin()) {
Serial.println("ERROR: AHT not found");
while (1);
}
Serial.println("AHT OK");
if (!SensorA.begin()) {
Serial.println("ERROR: ENS160 not found");
while (1);
}
Serial.println("ENS160 OK");
SensorA.setMode(ENS160_OPMODE_STD);
Serial.println("All systems ready");
}
void loop() {
sensors_event_t boxHumidity, boxTemp;
ahtSensor.getEvent(&boxHumidity, &boxTemp);
float valorTemp = boxTemp.temperature;
float valorHum = boxHumidity.relative_humidity;
SensorA.set_envdata210(
(uint16_t)((valorTemp + 273.15f) * 64.0f),
(uint16_t)(valorHum * 512.0f)
);
if (SensorA.measure(true)) {
Serial.print("Temp: "); Serial.print(valorTemp); Serial.println(" C");
Serial.print("Humidity: ");Serial.print(valorHum); Serial.println(" %");
Serial.print("eCO2: "); Serial.print(SensorA.geteCO2()); Serial.println(" ppm");
Serial.print("TVOC: "); Serial.print(SensorA.getTVOC()); Serial.println(" ppb");
Serial.println("---");
} else {
Serial.println("Waiting for ENS160 reading...");
}
delay(1000);
}
Key terms and functions used in this code — translated into plain language.
| Term / Command | What it does |
|---|---|
| sensors_event_t | Acts as two special shipping boxes. You hand them to the sensor and it fills them all at once with all the climate data. Example: sensors_event_t boxHumidity, boxTemp; |
| getEvent(&box1, &box2) | Used to collect the information from the boxes. The & symbol tells the board "here is the address of the box — fill it." Example: mySensor.getEvent(&boxHumidity, &boxTemp); |
| geteCO2() | Retrieves the eCO2 reading from the ENS160 library. The get prefix is the library's naming convention for reading data. |
| set_envdata210() | Sends the temperature (in Kelvin × 64) and humidity (× 512) to the ENS160 so it can compensate and calibrate its gas readings. |
| setMode(ENS160_OPMODE_STD) | Activates the sensor's standard operating mode — turns on the internal heaters so gas detection can begin. |
| while(1) | An infinite loop used to halt the program permanently when a critical error is detected (e.g., sensor not found). |
When electronic boards need to communicate with each other they use something called protocols, the best known are: I2C, SPI, UART, Wi-FI, Bluetooth, ESP-NOW, HTTP, MQTT. I first tried connecting my ESP32-C6 with an RP2350 using I2C cables (SDA and SCL), but I2C can sometimes be unstable. My RP2350 froze completely and stopped accepting uploads. To connect two boards safely with cables, UART became my next option: it uses one dedicated wire to send and another dedicated wire to receive.
I connected both I2C lines in parallel on a breadboard. One great feature of I2C is that you can connect multiple devices on the same two wires, you just need to give them different addresses.
SDA RP2350 → SDA ESP32-C6SCL RP2350 → SCL ESP32-C6GND RP2350 → GND ESP32-C6 (shared ground is required)During I2C experimentation, the RP2350 froze and stopped accepting uploaded code entirely. If your RP2350 ever freezes like this, you can revive it easily: hold down the BOOT button, connect the USB cable to the computer, count five seconds slowly, then release the button (It took me 4 hours to figure that out).
UART communication was implemented between the XIAO ESP32-C6 and the RP2350 using TX and RX serial lines. Both boards acted as communication nodes capable of sending and receiving data. Each board was defined as either transmitter or receiver.
For a more detailed reference on communication protocols, I recommend this page by Oliver Ochoa: gulden8ag.github.io → Networking & Communications
| Command | Description |
|---|---|
| Serial.begin() | Starts serial communication. |
| rpSerial.begin() | Initializes UART communication between boards. |
| Servo.attach() | Connects the servo to a selected pin. |
| Servo.write() | Moves the servo to a specific angle. |
| Serial.println() | Prints messages to the serial monitor. |
| rpSerial.println() | Sends UART messages to the RP2350. |
| rpSerial.available() | Checks if incoming UART data is available. |
| readStringUntil() | Reads incoming serial data until a specific character is found. |
| delay() | Pauses the program for a specified time. |
| Command | Description |
|---|---|
| Serial.begin() | Starts USB serial communication for debugging. |
| Serial1.begin() | Initializes UART communication with the ESP32. |
| ring.begin() | Initializes the NeoPixel ring. |
| ring.setBrightness() | Adjusts the LED brightness level. |
| ring.setPixelColor() | Sets the color of individual LEDs. |
| ring.show() | Updates and displays the LED changes. |
| ColorHSV() | Generates colors using HSV values. |
| substring() | Extracts values from incoming UART commands. |
| toInt() | Converts received text into integer values. |
| Serial1.available() | Checks if UART data is available. |
| readStringUntil() | Reads incoming UART data until a character is found. |
During the communication tests I encountered several issues: unstable I2C support communication on the RP2350, upload conflicts while using serial communication and ultimately switched from I2C to UART, which provided a more stable and reliable connection between both microcontrollers.
After confirming that the sensors worked and reviewing the programming fundamentals, I moved directly to MQTT — the protocol that matters most for my final project. I decided to use MQTT because it is the most efficient way to send short messages over Wi-Fi. It is perfect for my project because it allows the microcontroller to publish real-time air quality readings quickly and reliably.
The central server that receives and distributes messages. For this week I used the public server at broker.hivemq.com.
Responsible for sending data. In my project the XIAO ESP32-C6 is the publisher — it sends eCO2 and TVOC values.
The "address" where data is published — the exact label or channel where the information lives.
MQTT_EVENT_CONNECTED: subscribe + publish "online" status.MQTT_EVENT_DATA: parse command topic/payload → act → publish acknowledgment/status.Download the PubSubClient.h library, it handles the entire MQTT protocol. The WiFi.h library comes pre-installed in the ESP32 core, so no separate installation is needed.
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <ScioSense_ENS160.h>
#include <Adafruit_AHTX0.h>
// --- Wi-Fi Credentials ---
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";
// --- MQTT Configuration ---
const char* mqtt_server = "broker.hivemq.com";
const char* topic_eco2 = "nicole/monitor/eco2";
const char* topic_tvoc = "nicole/monitor/tvoc";
const char* topic_temperature = "nicole/monitor/temperatura";
WiFiClient espClient;
PubSubClient client(espClient);
// --- Sensor Objects ---
ScioSense_ENS160 ens160(0x53);
Adafruit_AHTX0 aht21;
// --- Wi-Fi Setup ---
void setup_wifi() {
delay(10);
Serial.print("\nConnecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected!");
}
// --- MQTT Reconnect ---
void reconnect() {
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
String clientId = "XIAO_C6_Nicole_";
clientId += String(random(0xffff), HEX);
if (client.connect(clientId.c_str())) {
Serial.println("Connected to Broker!");
} else {
Serial.print("Failed, rc=");
Serial.print(client.state());
Serial.println(" retrying in 5 seconds");
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println("=== MainESP-C6 Starting ===");
setup_wifi();
client.setServer(mqtt_server, 1883);
Wire.begin(D4, D5);
if (!aht21.begin()) {
Serial.println("[ERROR] AHT21 not found.");
while (1) delay(500);
}
if (!ens160.begin()) {
Serial.println("[ERROR] ENS160 not found.");
while (1) delay(500);
}
ens160.setMode(ENS160_OPMODE_STD);
Serial.println("=== System ready. Reading data... ===\n");
}
void loop() {
if (!client.connected()) reconnect();
client.loop();
sensors_event_t evtHum, evtTemp;
aht21.getEvent(&evtHum, &evtTemp);
float temperature = evtTemp.temperature;
float humidity = 50.0; // placeholder
ens160.set_envdata210(
(uint16_t)((temperature + 273.15f) * 64.0f),
(uint16_t)(humidity * 512.0f)
);
if (ens160.measure(true)) {
uint16_t eco2 = ens160.geteCO2();
uint16_t tvoc = ens160.getTVOC();
Serial.print("Temp:"); Serial.print(temperature); Serial.println("C");
Serial.print("eCO2: "); Serial.print(eco2); Serial.println(" ppm");
Serial.print("TVOC: "); Serial.print(tvoc); Serial.println(" ppb");
char eco2String[10], tvocString[10], temperatureString[10];
sprintf(eco2String, "%d", eco2);
sprintf(tvocString, "%d", tvoc);
sprintf(temperatureString, "%.1f", temperature);
client.publish(topic_eco2, eco2String);
client.publish(topic_tvoc, tvocString);
client.publish(topic_temperature, temperatureString);
Serial.println("-> Data published to HiveMQ");
}
delay(2000);
}
WiFi.h — Gives the microcontroller access to your local Wi-Fi network.PubSubClient.h — Handles all MQTT message sending and receiving.Wire.h — Required to build the physical I2C connection with the hardware.ScioSense_ENS160.h — Controls the gas sensor to extract eCO2 and TVOC values.Adafruit_AHTX0.h — Reads temperature and humidity data.After the libraries, the code defines the router credentials, the public broker address, and the specific topics where the dashboard will listen.
setup_wifi() — Starts the connection process to your home router.reconnect() — Keeps the system online. If the internet drops, it retries every 5 seconds. It also generates a unique Client ID to avoid conflicts with other users sharing the public HiveMQ server.Wire.begin(D4, D5) — Maps I2C communication to pins D4 (SDA) and D5 (SCL) of the XIAO ESP32-C6.aht21.begin() / ens160.begin() — Wakes up both sensors. If either fails, the program halts in an infinite loop to prevent crashes.ens160.setMode(ENS160_OPMODE_STD) — Activates standard mode — turns on the internal heaters to start detecting gases.client.setServer(mqtt_server, 1883) — Tells the board the exact destination and port for data delivery.set_envdata210() — Converts temperature to Kelvin (×64) and humidity (×512) — the exact format the ENS160 needs to calibrate its gas readings.sprintf() — Converts raw sensor numbers into text strings for publishing. The %.1f trick limits temperature to one decimal place (e.g. 25.3).client.publish() — Sends the final text values over the internet to their specific MQTT topics.To confirm that the data was actually reaching the server, I used HiveMQ's public WebSocket client in the browser to subscribe to the topics and monitor the live data flow.
const char*.
There was a clash between data types when using sprintf for the temperature value: the system expected an integer (%d) but received a float with decimal places. I fixed it by changing the format to %f first, then refined it to %.1f to show only one decimal (e.g., 25.3).
To see if I would be able to use this for my final interface I aked Gemini to make the control interface. I worked really good and I found it very interesting how it uses JavaScript to connect to the WebSocket Broker and updating everything. This is the page, it is in my current fab academy repsoitory. To see the code just go to de site, do a right click and click on View page source. This is a video of how everyting works together:
This week I used the I2C protocol to validate readings from the ENS160 + AHT21 sensors on a single data bus. For the final project architecture, I tested a communication network that will rely exclusively on the MQTT protocol over Wi-Fi. By connecting the microcontroller to the wireless network via MQTT, the air quality monitor will be able to transmit eCO2 and TVOC in real time, enabling the system to respond to environmental changes intelligently, remotely, and automatically.
This week I accidentally killed and then revived my RP2350. It stopped accepting uploads entirely after some I2C experimentation and I spent hours trying to fix it. The solution: press and hold BOOT, connect the USB cable, wait 5 seconds, then release the button. The board came back to life immediately. Because of this I ended up using UART communication instead of I2C.