From Code to Reality: Building a Production-Grade IoT Device

From Code to Reality: Building a Production-Grade IoT Device

Series.log

Hardware Journey

10 / 10
CONTENTS.log
📑 Table of Contents
Bill of Materials
QTY: 5
[1x]
ESP32-WROOM-32 Dev Kit C V4 // The workhorse of modern IoT. Accept no substitutes.
SOURCE_LINK
[1x]
DHT22 Temperature & Humidity Sensor // Higher accuracy than the DHT11, worth the extra pennies.
SOURCE_LINK
[1x]
0.96 inch OLED Display (SSD1306) // I2C interface required to save GPIO pins.
SOURCE_LINK
[1x]
Breadboard Power Supply Module (3.3V/5V) // Don't rely on USB power for WiFi-heavy loads.
SOURCE_LINK
[1x]
Jumper Wires (Assorted M/M, M/F) // High quality copper, not aluminum.
SOURCE_LINK

* SYSTEM.NOTICE: Affiliate links support continued laboratory research.

Welcome to Day 10 of “The Software Developer’s Hardware Journey.”

If you’ve been following along, we’ve come a long way. We started with the humble LED, moved through the mysteries of Ohm’s Law, wrestled with datasheets, and finally designed our own PCB. Today, we cross the final frontier: The Internet of Things (IoT).

For a software developer, IoT is the sweet spot. It’s where the physical world we’ve been learning to control finally meets the data-driven world we know and love. But let me be clear: “Hello World” in IoT isn’t just blinking an LED anymore. It’s blinking an LED from a continent away, securely, reliably, and with telemetry.

Today, we are going to build a production-grade environment monitor. This isn’t a toy. It’s a vertical slice of a real-world product, involving:

  1. Hardware: Wiring up sensors and displays.
  2. Firmware: Writing non-blocking, asynchronous C++ for the ESP32.
  3. Connectivity: Implementing robust MQTT communication.
  4. Security: Managing Certificates and encryption.
  5. Cloud: Visualizing data in real-time.

Let’s build something real.

Architectural diagram showing the full IoT system flow from device to broker to cloud database and mobile app

The Architecture: Why We Don’t Just Use HTTP

As web developers, our instinct is to send a POST request.

In the world of constrained devices, HTTP is heavy. It requires opening a connection, sending headers, waiting for a response, and closing the connection. It’s chatty and power-hungry.

Instead, we use MQTT (Message Queuing Telemetry Transport).

MQTT is a lightweight, publish-subscribe protocol running over TCP/IP. It’s designed for erratic networks and low power.

  • The Broker: The central post office. It doesn’t look at the mail; it just routes it.
  • The Publisher (Our ESP32): Sends data to a “topic” (e.g., device/123/temp).
  • The Subscriber (Our Dashboard): Listens to that topic and reacts.

This decoupling is powerful. Our device doesn’t know or care who is listening. It just reports its state and goes back to sleep.

Conceptual visualization of MQTT messaging protocol showing Publisher and Subscriber nodes

The Hardware Setup

We are using the ESP32 for this project. Unlike the Arduino Uno regarding which we spoke in Post 7, the ESP32 is a beast. It has dual cores, built-in WiFi and Bluetooth, and enough RAM to handle TLS encryption—a non-negotiable requirement for modern IoT.

Here is the pinout we need to be aware of. Notice the multiple power rails and the dedicated layout for ADC and communication protocols.

Stylized pinout diagram of an ESP32-WROOM development board highlighting GPIO pins

Scaling the Wiring

We are connecting the DHT22 sensor to measure our environment and an OLED screen to give us immediate local feedback.

  • DHT22 Data -> GPIO 4
  • OLED SDA -> GPIO 21
  • OLED SCL -> GPIO 22
  • VCC -> 3.3V Rail
  • GND -> Common Ground

Pro Tip: The ESP32 3.3V regulator can get hot if you pull too much current. For the OLED and sensors, it’s usually fine, but if you add relays or motors, use an external power supply.

Realistic close-up of a DHT22 temperature sensor wired to a breadboard

The Firmware (The Code)

This is where your software skills pay off. But beware: embedded C++ is not TypeScript. There is no garbage collector. Memory leaks don’t slow you down; they crash your device.

We will use PlatformIO instead of the Arduino IDE. The file structure and dependency management (via platformio.ini) are far superior for complex projects.

platformio.ini Configuration

First, let’s define our environment and dependencies. We need libraries for the sensor, the display, and the MQTT client.

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
    adafruit/Adafruit Unified Sensor @ ^1.1.9
    adafruit/DHT sensor library @ ^1.4.4
    adafruit/Adafruit GFX Library @ ^1.11.5
    adafruit/Adafruit SSD1306 @ ^2.5.7
    knolleary/PubSubClient @ ^2.8

The Header File: config.h

Never hardcode credentials in your main logic. We separate them here. In a real production environment, these would be injected during the build process or stored in NVS (Non-Volatile Storage).

#ifndef CONFIG_H
#define CONFIG_H

const char* SSID = "YOUR_WIFI_SSID";
const char* PASSWORD = "YOUR_WIFI_PASSWORD";

// MQTT Broker (Using a public test broker for this example, use AWS IoT in prod)
const char* MQTT_SERVER = "test.mosquitto.org"; 
const int MQTT_PORT = 1883; // Use 8883 for SSL/TLS
const char* MQTT_TOPIC_PUB = "technochips/device/data";
const char* MQTT_TOPIC_SUB = "technochips/device/command";

#endif

The Main Logic: main.cpp

We need to implement a non-blocking loop. The delay() function is the enemy. It floods the CPU and prevents network tasks from running. Instead, we use millis() to track time.

This pattern is essentially a manual event loop—something JavaScript developers should recognize immediately.

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "config.h"

// Hardware Definitions
#define DHTPIN 4
#define DHTTYPE DHT22
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64

// Objects
DHT dht(DHTPIN, DHTTYPE);
WiFiClient espClient;
PubSubClient client(espClient);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// Variables for Non-Blocking Loop
unsigned long lastMsg = 0;
const long interval = 5000; // Send data every 5 seconds

void setup_wifi() {
  delay(10);
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(SSID);

  WiFi.begin(SSID, PASSWORD);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

void callback(char* topic, byte* payload, unsigned int length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  for (int i = 0; i < length; i++) {
    Serial.print((char)payload[i]);
  }
  Serial.println();
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // Create a random client ID
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);
    
    // Attempt to connect with LWT (Last Will and Testament)
    // If this device dies unexpectedly, the broker will publish "offline" to the status topic
    if (client.connect(clientId.c_str(), NULL, NULL, "technochips/device/status", 1, true, "offline")) {
      Serial.println("connected");
      // Publish "online" status (retained)
      client.publish("technochips/device/status", "online", true);
      client.subscribe(MQTT_TOPIC_SUB);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  
  // Initialize Sensor
  dht.begin();
  
  // Initialize Display
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    for(;;);
  }
  display.display();
  delay(2000);
  display.clearDisplay();

  setup_wifi();
  client.setServer(MQTT_SERVER, MQTT_PORT);
  client.setCallback(callback);
}

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  client.loop(); // Keeps the MQTT connection alive

  unsigned long now = millis();
  if (now - lastMsg > interval) {
    lastMsg = now;

    // Read Data
    float h = dht.readHumidity();
    float t = dht.readTemperature();

    if (isnan(h) || isnan(t)) {
      Serial.println("Failed to read from DHT sensor!");
      return;
    }

    // Create JSON Payload
    String payload = "{";
    payload += "\"temperature\":"; 
    payload += t;
    payload += ", \"humidity\":";
    payload += h;
    payload += "}";

    Serial.print("Publishing message: ");
    Serial.println(payload);
    
    client.publish(MQTT_TOPIC_PUB, (char*) payload.c_str());

    // Update Display
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);
    
    display.setCursor(0,0);
    display.println("IoT Monitor");
    
    display.setTextSize(2);
    display.setCursor(0,20);
    display.print(t);
    display.print(" C");
    
    display.setCursor(0,45);
    display.print(h);
    display.print(" %");
    
    display.display();
  }
}

Understanding the Payload

The code significantly constructs a JSON payload. The structure is simple:

{
  "temperature": 24.5,
  "humidity": 48.0
}

This standardization is crucial. By sending structured JSON, we ensure that any subscriber—be it a Python script, a Node.js backend, or indeed an AWS Lambda function—can parse and use this data without knowing the specifics of the hardware that sent it.

Visualization of a JSON data payload floating in a digital space

Deep Dive: MQTT Protocol Internals

Let’s pause the coding for a moment to understand why MQTT is so resilient. It offers features that HTTP simply cannot match without massive overhead.

Quality of Service (QoS)

MQTT defines three levels of assurance for message delivery. This is a contract between the sender and the receiver (or broker).

  1. QoS 0: At most once

    • “Fire and Forget”. The message is sent, and no confirmation is expected.
    • Use case: Temperature data. If one reading is lost, another one is coming in 5 seconds. It doesn’t matter.
    • Overhead: Lowest.
  2. QoS 1: At least once

    • “Acknowledged Delivery”. The sender stores the message until it gets a PUBACK from the receiver. If no ACK is received, it resends.
    • Risk: You might get duplicate messages.
    • Use case: Critical alerts like “Motion Detected”. You definitely want to know, even if you are told twice.
    • Overhead: Medium.
  3. QoS 2: Exactly once

    • “Guaranteed Delivery”. A four-step handshake ensures the message arrives exactly once.
    • Use case: Financial transactions or billing pulses.
    • Overhead: High. Avoid unless absolutely necessary.

In our firmware code above, client.publish() defaults to QoS 0. If we were building a medical device, we would surely upgrade to QoS 1 or 2.

Last Will and Testament (LWT)

You might have noticed this line in the reconnect() function:

if (client.connect(clientId.c_str(), NULL, NULL, "technochips/device/status", 1, true, "offline"))

This is LWT. It tells the broker: “If I disconnect ungracefully (crash, power loss, network failure) and don’t send a DISCONNECT packet, please publish ‘offline’ to the technochips/device/status topic on my behalf.”

This is how dashboards know a device is down instantly, without polling. It’s a game-changer for system reliability.

Security - The “S” in IoT

There is an old joke: The “S” in IoT stands for Security.

Most hobby projects skip this. We effectively cannot. If you are deploying a device into someone’s home or a factory floor, you must secure the channel.

The basic MQTT code above uses unencrypted TCP (Port 1883). Anyone with Wireshark on the same network can see your data.

To fix this, we need TLS/SSL (Transport Layer Security). This involves:

  1. Root CA: Proves the server is who it says it is.
  2. Client Certificate: Proves the device is allowed to talk to the server.
  3. Private Key: The device’s secret signature.

In the ESP32 code, this changes the connection setup slightly using WiFiClientSecure:

WiFiClientSecure espClient;

// Load certificates from PROGMEM or SPIFFS
const char* root_ca = \
"-----BEGIN CERTIFICATE-----\n" \
"MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF\n" \
"ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6\n" \
"on... (rest of certificate)\n" \
"-----END CERTIFICATE-----\n";

void setup_security() {
  espClient.setCACert(root_ca);
  // espClient.setCertificate(client_cert); // Mutual Auth
  // espClient.setPrivateKey(private_key);  // Mutual Auth
}

This encryption ensures that even if the data packets are intercepted, they are computationally impossible to read.

Symbolic representation of IoT security with a digital padlock protecting a microchip

Building the Backend (Node.js)

Now that our device is shouting data into the void, let’s build something to catch it. We’ll create a simple Node.js server that acts as a bridge between MQTT and a WebSocket frontend.

Prerequisites

  • Node.js (v18+)
  • mqtt package
  • ws package (for WebSockets)

server.js

/*
 * Simple IoT Backend Bridge
 * Listens to MQTT and broadcasts via WebSocket
 */

const mqtt = require('mqtt');
const WebSocket = require('ws');

// MQTT Configuration
const MQTT_BROKER = 'mqtt://test.mosquitto.org';
const TOPIC_DATA = 'technochips/device/data';
const TOPIC_STATUS = 'technochips/device/status';

// WebSocket Server
const wss = new WebSocket.Server({ port: 8080 });

// MQTT Client
const client = mqtt.connect(MQTT_BROKER);

client.on('connect', () => {
    console.log('Connected to MQTT Broker');
    client.subscribe([TOPIC_DATA, TOPIC_STATUS], (err) => {
        if (!err) {
            console.log(`Subscribed to: ${TOPIC_DATA}, ${TOPIC_STATUS}`);
        }
    });
});

client.on('message', (topic, message) => {
    const payload = message.toString();
    console.log(`Received [${topic}]: ${payload}`);
    
    // Broadcast to all connected WebSocket clients
    wss.clients.forEach((ws) => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({
                type: topic === TOPIC_DATA ? 'telemetry' : 'status',
                topic: topic,
                payload: JSON.parse(payload),
                timestamp: new Date().toISOString()
            }));
        }
    });
});

wss.on('connection', (ws) => {
    console.log('New Client Connected');
    ws.send(JSON.stringify({ type: 'info', message: 'Connected to IoT Bridge' }));
});

console.log('WebSocket Server running on port 8080');

This script creates a real-time pipeline. As soon as the ESP32 publishes a temperature reading:

  1. MQTT Broker receives it.
  2. Our Node.js script (Subscriber) receives it.
  3. Node.js script converts it to a WebSocket message.
  4. All connected browser clients receive it instantly.

The Cloud Dashboard (React)

What good is data if you can’t see it?

We can subscribe to our MQTT topic using a tool like MQTT Explorer for debugging, but for a user, we need a dashboard.

In a professional stack, you might ingest this data into AWS IoT Core, route it via a Rule to AWS Timestream, and visualize it with Grafana.

For our example, let’s imagine the data flow to a custom React dashboard.

Modern cloud dashboard interface showing real-time graphs

The Frontend Code (Dashboard.jsx)

We’ll use recharts for visualization.

import React, { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

const Dashboard = () => {
    const [data, setData] = useState([]);
    const [status, setStatus] = useState('offline');

    useEffect(() => {
        const ws = new WebSocket('ws://localhost:8080');

        ws.onmessage = (event) => {
            const message = JSON.parse(event.data);
            
            if (message.type === 'telemetry') {
                setData(prev => {
                    const newData = [...prev, {
                        time: new Date().toLocaleTimeString(),
                        temp: message.payload.temperature,
                        humid: message.payload.humidity
                    }];
                    // Keep only last 20 readings
                    return newData.slice(-20);
                });
            } else if (message.type === 'status') {
                setStatus(message.payload); // 'online' or 'offline' via LWT
            }
        };

        return () => ws.close();
    }, []);

    return (
        <div className="p-6 bg-slate-900 min-h-screen text-white">
            <header className="flex justify-between items-center mb-8">
                <h1 className="text-3xl font-bold">IoT Environment Monitor</h1>
                <span className={`px-4 py-1 rounded-full ${status === 'online' ? 'bg-green-500' : 'bg-red-500'}`}>
                    {status.toUpperCase()}
                </span>
            </header>

            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
                <div className="bg-slate-800 p-4 rounded-xl shadow-lg">
                    <h2 className="text-xl mb-4 text-orange-400">Temperature (°C)</h2>
                    <div className="h-64">
                        <ResponsiveContainer width="100%" height="100%">
                            <LineChart data={data}>
                                <XAxis dataKey="time" stroke="#94a3b8" />
                                <YAxis stroke="#94a3b8" />
                                <Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none' }} />
                                <Line type="monotone" dataKey="temp" stroke="#f97316" strokeWidth={2} dot={false} />
                            </LineChart>
                        </ResponsiveContainer>
                    </div>
                </div>
                
                <div className="bg-slate-800 p-4 rounded-xl shadow-lg">
                    <h2 className="text-xl mb-4 text-blue-400">Humidity (%)</h2>
                    <div className="h-64">
                        <ResponsiveContainer width="100%" height="100%">
                            <LineChart data={data}>
                                <XAxis dataKey="time" stroke="#94a3b8" />
                                <YAxis stroke="#94a3b8" />
                                <Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none' }} />
                                <Line type="monotone" dataKey="humid" stroke="#3b82f6" strokeWidth={2} dot={false} />
                            </LineChart>
                        </ResponsiveContainer>
                    </div>
                </div>
            </div>
        </div>
    );
};

export default Dashboard;

This is a complete, functioning dashboard. It listens for the LWT “offline” message to turn the status badge red, and it updates the charts in real-time as new telemetry flows in.

The Mobile Experience

In 2026, users expect to control their devices from their phones.

The communication flow reverses here. The phone app publishes a message to technochips/device/command, for example: {"relay": "ON"}.

The ESP32, which is subscribed to this topic, receives the message instantaneously (thanks to the persistent TCP connection of MQTT) and flips the GPIO pin.

Clean UI mockup of a mobile app controlling an IoT device

Power Management and Deep Sleep

If your device is plugged into the wall, power is cheap. If it’s on a battery, every milliamp-hour counts.

The ESP32 is power-hungry when WiFi is on (~150mA). A standard 2000mAh LiPo battery would last less than a day.

To fix this, we use Deep Sleep.

Deep sleep shuts down the CPU, WiFi, and Bluetooth, leaving only the RTC (Real Time Clock) running. The power consumption drops to roughly 10µA.

The workflow becomes:

  1. Wake up.
  2. Connect to WiFi.
  3. Read Sensor.
  4. Publish MQTT.
  5. Go to Sleep for 10 minutes.
// Sleep for 10 minutes (in microseconds)
esp_sleep_enable_timer_wakeup(10 * 60 * 1000000);
esp_deep_sleep_start();

This simple change can extend battery life from 12 hours to 6 months.

Conceptual illustration of IoT power management showing energy saving modes

Over-The-Air (OTA) Updates

You’ve shipped your device to customers. You find a bug. Now what?

You can’t ask them to plug in a USB cable. You need OTA.

OTA allows the device to download a new binary file from a server and flash itself.

  1. Device checks for update check URL (e.g., api.technochips.org/firmware/latest).
  2. Server responds with a version number.
  3. If version > current, device downloads the .bin file.
  4. Device writes to the destination partition.
  5. Device reboots into the new firmware.

This is the holy grail of embedded development. It turns static hardware into evolving software products.

Visualization of an Over-the-Air OTA firmware update

Troubleshooting: When It Doesn’t Work

Hardware bugs are harder to debug than software bugs because you can’t always just “step through” them. Here are the most common issues beginners face.

1. Brownouts (The Random Reset)

Symptom: The ESP32 restarts randomly, especially when connecting to WiFi. You see “Brownout detector was triggered” in the serial monitor. Cause: The WiFi chip draws a sudden spike of current, causing the voltage to dip below 2.5V. Fix: Add a 10µF or 100µF capacitor across the VCC and GND pins of your module. Better yet, get a better power supply. USB ports on laptops are notoriously weak.

2. MQTT Connection Refused (RC=-2)

Symptom: Connection fails repeatedly. Cause: Network firewall or incorrect credentials. Fix:

  • Check if Port 1883 (or 8883 for SSL) is blocked by your router.
  • Verify the ClientID is unique. Some brokers (like AWS IoT) kick off the old connection if a new connection uses the same ID.

3. Sensor Reading “nan”

Symptom: dht.readTemperature() returns nan (Not a Number). Cause: Timing issue or bad wiring. Fix:

  • Ensure the pull-up resistor (4.7kΩ) is between the Data pin and VCC (most modules have this built-in).
  • Don’t poll the DHT22 faster than once every 2 seconds. It’s a slow sensor.

4. Code Upload Fails

Symptom: “A fatal error occurred: Failed to connect to ESP32: Timed out…”. Cause: The ESP32 needs to be in Bootloader mode. Fix: Hold the BOOT button on the DevKit while the upload starts. Release it once you see the “Connecting…” message change to “Writing…”.

Moving to the Big League: AWS IoT Core

Our mosquitto.org test broker is great for prototypes, but it won’t scale to 10,000 devices. For that, we need a managed service like AWS IoT Core.

As software developers, we are used to AWS. IoT Core is just another service, but with a different protocol.

1. The Thing Registry

In AWS, every device is a “Thing.” It has a:

  • Shadow: A JSON document representing its state (desired vs. reported).
  • Certificate: The X.509 cert we discussed earlier.
  • Policy: An IAM-like document defining what it can do.

2. The Policy

Here is a restrictive policy that allows a device to only publish to its own topic. This is crucial for security.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "arn:aws:iot:us-east-1:123456789012:client/ESP32-*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Publish",
      "Resource": "arn:aws:iot:us-east-1:123456789012:topic/technochips/device/data"
    }
  ]
}

3. The Rules Engine

This is where the magic happens. You can write SQL-like queries to route incoming MQTT messages to other AWS services.

Query:

SELECT temperature, humidity, timestamp() as time FROM 'technochips/device/data' WHERE temperature > 30

Action:

  • SNS: Send me a text message (“Server Room Overheating!”).
  • Lambda: Run a function to turn on the AC.
  • Timestream: Store the data for long-term analytics.
  • DynamoDB: Update the current state user dashboard.

This integration turns your hardware into a native part of your cloud infrastructure.

Deep Dive: Analyzing Your Data with Python

After running your device for a week, you’ll have thousands of data points. Let’s act like data scientists and analyze the thermal performance of our room.

We’ll use Pandas and Matplotlib. Assuming you saved your logs to a CSV or pulled them from AWS Timestream:

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Load the data
df = pd.read_csv('sensor_logs.csv')

# Convert timestamp string to datetime objects
df['timestamp'] = pd.to_datetime(df['timestamp'])

# Set timestamp as index for easier resampling
df.set_index('timestamp', inplace=True)

# Resample to hourly averages to smooth out noise
hourly_avg = df.resample('H').mean()

# Calculate statistics
max_temp = df['temperature'].max()
min_temp = df['temperature'].min()
avg_humidity = df['humidity'].mean()

print(f"Max Temp: {max_temp}°C")
print(f"Min Temp: {min_temp}°C")
print(f"Avg Humidity: {avg_humidity:.1f}%")

# Correlation Analysis
correlation = df['temperature'].corr(df['humidity'])
print(f"Correlation between Temp and Humidity: {correlation:.2f}")

# Plotting
plt.figure(figsize=(12, 6))
sns.set_theme(style="darkgrid")

# Create dual-axis plot
ax1 = plt.gca()
ax2 = ax1.twinx()

sns.lineplot(data=hourly_avg, x=hourly_avg.index, y='temperature', ax=ax1, color='orange', label='Temperature')
sns.lineplot(data=hourly_avg, x=hourly_avg.index, y='humidity', ax=ax2, color='blue', label='Humidity')

ax1.set_ylabel('Temperature (°C)', color='orange')
ax2.set_ylabel('Humidity (%)', color='blue')
plt.title('Weekly Environmental Trends')

plt.show()

Findings

You might discover:

  1. Thermal Lag: The room stays hot for 2 hours after the AC turns off.
  2. Humidity Spikes: Every time you cook, humidity jumps 10%.
  3. Sensor Noise: Occasional outliers that need filtering (software debouncing).

This feedback loop—build, measure, analyze, improve—is the core of engineering, whether software or hardware.

Moving to Production

The breadboard is where ideas are born, but it’s not where they live.

To make this a “finished” product, we move from jumper wires to a custom PCB (as we learned in Post 9) and a 3D-printed enclosure.

Soldering headers onto the final board gives us a robust, vibration-resistant connection. It’s a permanent commitment to the circuit you’ve designed.

Extreme close-up of soldering pin headers onto an ESP32 board

Conclusion

We have built a system that spans the entire technology stack.

We wrote C++ that talks to silicon. We sent packets across the airwaves. We routed data through the cloud. We visualized it on a screen.

This is the power of the Full Stack Hardware Developer. You are no longer constrained by the browser window or the server rack. You have the power to instrument the world, to gather its data, and to affect it physically.

The journey doesn’t end here. It only gets deeper. FPGAs, RTOS, custom silicon… the rabbit hole is infinite.

But for now, look at your desk. That little board, blinking away, sending its temperature to the cloud? That’s magic. And you built it.

See you in the next build.

Appendix: Full Source Code

For those attempting the full build, here is the robust version of the main loop, including error handling and reconnection logic often skipped in tutorials.

/*
 * Full Production-Ready IoT Monitor
 * Author: Rahul
 * Date: 2026-02-17
 * License: MIT
 */

#include <Arduino.h>
#include <WiFi.h>
#include "time.h"
#include <PubSubClient.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "config.h"

// Hardware Definitions
#define DHTPIN 4
#define DHTTYPE DHT22
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64

// Objects
DHT dht(DHTPIN, DHTTYPE);
WiFiClient espClient;
PubSubClient client(espClient);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// Variables for Non-Blocking Loop
unsigned long lastMsg = 0;
const long interval = 5000; // Send data every 5 seconds

// NTP Server settings for accurate timestamps
const char* ntpServer = "pool.ntp.org";
const long  gmtOffset_sec = 0;
const int   daylightOffset_sec = 3600;

void printLocalTime(){
  struct tm timeinfo;
  if(!getLocalTime(&timeinfo)){
    Serial.println("Failed to obtain time");
    return;
  }
  Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");
}

void setup_wifi() {
  delay(10);
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(SSID);

  WiFi.begin(SSID, PASSWORD);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
  
  // Init and get the time
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  printLocalTime();
}

void callback(char* topic, byte* payload, unsigned int length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  String messageTemp;
  for (int i = 0; i < length; i++) {
    Serial.print((char)payload[i]);
    messageTemp += (char)payload[i];
  }
  Serial.println();

  // Switch on the LED if an 1 was received as first character
  if ((char)payload[0] == '1') {
    // digitalWrite(BUILTIN_LED, LOW);   // Turn the LED on (Note that LOW is the voltage level
    // but actually the LED is on; this is true for ESP-01)
  } else {
    // digitalWrite(BUILTIN_LED, HIGH);  // Turn the LED off by making the voltage HIGH
  }
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);
    
    // Attempt to connect with LWT
    if (client.connect(clientId.c_str(), NULL, NULL, "technochips/device/status", 1, true, "offline")) {
      Serial.println("connected");
      client.publish("technochips/device/status", "online", true);
      client.subscribe(MQTT_TOPIC_SUB);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

// Robust Reconnection Strategy
void ensureNetwork() {
    if (WiFi.status() != WL_CONNECTED) {
        Serial.println("WiFi Lost. Reconnecting...");
        WiFi.disconnect();
        WiFi.reconnect();
        
        // Wait up to 10 seconds for WiFi
        int attempts = 0;
        while (WiFi.status() != WL_CONNECTED && attempts < 20) {
            delay(500);
            Serial.print(".");
            attempts++;
        }
    }
    
    if (WiFi.status() == WL_CONNECTED && !client.connected()) {
        reconnect();
    }
}

void setup() {
  Serial.begin(115200);
  
  dht.begin();
  
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    for(;;);
  }
  display.display();
  delay(2000);
  display.clearDisplay();

  setup_wifi();
  client.setServer(MQTT_SERVER, MQTT_PORT);
  client.setCallback(callback);
}

void loop() {
    ensureNetwork();
    client.loop();
    
    // Heartbeat for debugging
    static unsigned long lastHeartbeat = 0;
    if (millis() - lastHeartbeat > 60000) {
        lastHeartbeat = millis();
        Serial.println("System Healthy. Heap: " + String(ESP.getFreeHeap()));
    }
    
    
    unsigned long now = millis();
    if (now - lastMsg > interval) {
      lastMsg = now;

      float h = dht.readHumidity();
      float t = dht.readTemperature();

      if (isnan(h) || isnan(t)) {
        Serial.println("Failed to read from DHT sensor!");
        return;
      }

      String payload = "{";
      payload += "\"temperature\":"; 
      payload += t;
      payload += ", \"humidity\":";
      payload += h;
      payload += "}";

      Serial.print("Publishing message: ");
      Serial.println(payload);
      
      client.publish(MQTT_TOPIC_PUB, (char*) payload.c_str());

      display.clearDisplay();
      display.setTextSize(1);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(0,0);
      display.println("IoT Monitor");
      display.setTextSize(2);
      display.setCursor(0,20);
      display.print(t);
      display.print(" C");
      display.setCursor(0,45);
      display.print(h);
      display.print(" %");
      display.display();
    }
}

Final Thoughts: The Loop is Closed

We started this series by looking at a simple circuit. Today, we’ve built a system that bridges the gap between the physical world and the digital cloud.

IoT isn’t just about “connecting things”; it’s about making those connections meaningful, secure, and resilient. By applying the software principles you already know—structuring data, securing channels, and building for scale—you’ve transformed a raw microcontroller into a production-ready product.

What’s next? Take this code and expand it. Add more sensors, build a more complex dashboard, or even try your hand at custom enclosure design. The hardware world is now your playground.

Thanks for joining me on Day 10. Stay curious, keep building, and I’ll see you in the next post.

Comments