Grafana Dashboard

ICT 360: Introduction to IoT

Mr. Seng Theara

Learning Objective

Learning Objectives

  •  Develop an IoT monitoring system using ESP32 and MQTT

  • Transmit and manage sensor data using a publish–subscribe architecture

  • Process and store time-series data using Node-RED and InfluxDB

  •  Visualize real-time environmental data using Grafana dashboards

  •  Apply MicroPython for cloud-integrated embedded systems

BMP280

BMP280

BMP280 is a digital sensor that measures:

  • 🌡 Temperature

  • 🌬 Air Pressure

  • 🏔 Altitude (calculated)

BMP280

We can build a simple weather prediction system using only BMP280 but has limitation

We can:

Limitation

  • Monitor pressure trend

  • Detect pressure drop

  • Estimate altitude

  • Log temperature

 

  • No humidity data

  • No wind speed

  • No rainfall detection

Air Pressure

Atmospheric or Air pressure is the force per unit area exerted by the weight of air molecules in the Earth's atmosphere.

P = \frac{F}{A}

Where:

  • PPP = Pressure

  • FFF = Force

  • AAA = Area

At sea level, average atmospheric pressure is:

1013.25 hPa or 101,325 Pa

*1 hPa = 100 Pa

Air Pressure

Atmospheric pressure exists because:

  • Air has mass

  • Mass experiences gravitational acceleration

  • Gravity pulls air molecules toward Earth

  • Air molecules collide with surfaces

Pressure is not static. it changes continuously due to:

  • Temperature

  • Altitude

  • Weather systems

  • Air movement

Temperature and Pressure Relationship

From Ideal Gas Law:

If volume is constant:

  • Increasing temperature increases pressure.

  • Decreasing temperature decreases pressure.

PV=nRT

In the atmosphere, this relationship is more complex due to air expansion and movement.

Resolution and Accuracy

Typical BMP280 specifications:

1 hPa difference corresponds to approx. 8 meters altitude difference.

  • Pressure resolution: 0.16 Pa

  • Absolute accuracy: ±1 hPa

  • Temperature resolution: 0.01°C

  • Drone altitude measurement

  • Indoor floor detection

  • Environmental monitoring

This makes it useful for:

Altitude

Altitude is the vertical distance of an object above a reference level.

Reason:

  • Air has mass.

  • Gravity pulls air downward.

  • Higher altitude → less air above.

  • Less air above → lower pressure.

Pressure Can Measure Altitude Atmospheric pressure decreases as altitude increases.

So pressure and altitude are inversely related.

Basic Relationship

1 hPa pressure decrease ≈ 8–9 meters altitude increase

Pressure Can Measure Altitude Atmospheric pressure decreases as altitude increases.

Example:

If pressure drops by 5 hPa:

Altitude increase ≈ 40 meters.

Applications of Barometric Altitude

Barometric altitude is used in:

  • Drones (height stabilization)

  • Aircraft altimeters

  • Hiking watches

  • Smartphone floor detection

  • Rocket experiments

  • Robotics navigation

Applications of Barometric Altitude

Barometric altitude is used in:

  • Drones (height stabilization)

  • Aircraft altimeters

  • Hiking watches

  • Smartphone floor detection

  • Rocket experiments

  • Robotics navigation

Testing BMP280 with ESP32

Testing BMP280 with ESP32

from machine import I2C
import time

class BMP280:
    def __init__(self, i2c, addr=0x76):
        self.i2c = i2c
        self.addr = addr
        self.sea_level_pressure = 1011.7

        chip_id = self.i2c.readfrom_mem(self.addr, 0xD0, 1)
        if chip_id[0] != 0x58:
            raise Exception("BMP280 not found")

        self.i2c.writeto_mem(self.addr, 0xE0, b'\xB6')
        time.sleep(0.2)

        calib = self.i2c.readfrom_mem(self.addr, 0x88, 24)

        def to_signed(val):
            if val > 32767:
                val -= 65536
            return val

        self.dig_T1 = calib[1] << 8 | calib[0]
        self.dig_T2 = to_signed(calib[3] << 8 | calib[2])
        self.dig_T3 = to_signed(calib[5] << 8 | calib[4])

        self.dig_P1 = calib[7] << 8 | calib[6]
        self.dig_P2 = to_signed(calib[9] << 8 | calib[8])
        self.dig_P3 = to_signed(calib[11] << 8 | calib[10])
        self.dig_P4 = to_signed(calib[13] << 8 | calib[12])
        self.dig_P5 = to_signed(calib[15] << 8 | calib[14])
        self.dig_P6 = to_signed(calib[17] << 8 | calib[16])
        self.dig_P7 = to_signed(calib[19] << 8 | calib[18])
        self.dig_P8 = to_signed(calib[21] << 8 | calib[20])
        self.dig_P9 = to_signed(calib[23] << 8 | calib[22])

        self.i2c.writeto_mem(self.addr, 0xF4, b'\x27')
        self.i2c.writeto_mem(self.addr, 0xF5, b'\xA0')

        self.t_fine = 0

    def _read_raw(self):
        data = self.i2c.readfrom_mem(self.addr, 0xF7, 6)
        adc_p = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
        adc_t = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4)
        return adc_t, adc_p

    def _compensate_temperature(self, adc_t):
        var1 = (((adc_t >> 3) - (self.dig_T1 << 1)) * self.dig_T2) >> 11
        var2 = (((((adc_t >> 4) - self.dig_T1) *
                  ((adc_t >> 4) - self.dig_T1)) >> 12) *
                self.dig_T3) >> 14
        self.t_fine = var1 + var2
        T = (self.t_fine * 5 + 128) >> 8
        return T / 100

    def _compensate_pressure(self, adc_p):
        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 = (((p << 31) - var2) * 3125) // var1
        var1 = (self.dig_P9 * (p >> 13) * (p >> 13)) >> 25
        var2 = (self.dig_P8 * p) >> 19
        p = ((p + var1 + var2) >> 8) + (self.dig_P7 << 4)
        return p / 256

    def _read_all(self):
        adc_t, adc_p = self._read_raw()
        temp = self._compensate_temperature(adc_t)
        pressure = self._compensate_pressure(adc_p)
        return temp, pressure

    @property
    def temperature(self):
        temp, _ = self._read_all()
        return temp

    @property
    def pressure(self):
        _, pressure = self._read_all()
        return pressure

    @property
    def altitude(self):
        pressure_hpa = self.pressure / 100
        return 44330 * (1 - (pressure_hpa / self.sea_level_pressure) ** 0.1903)

Save this as bmp280.py in micropython device

Testing BMP280 with ESP32

from machine import Pin, I2C
import bmp280
import time

i2c = I2C(0, scl=Pin(22), sda=Pin(21))
sensor = bmp280.BMP280(i2c)

while True:
    print("Temperature:", sensor.temperature, "°C")
    print("Pressure:", sensor.pressure / 100, "hPa")
    print("Altitude:", sensor.altitude, "m")
    print("---------------------------")
    time.sleep(2)

Modern IoT Data Architecture

 

Modern IoT Data Architecture

 

BMP280

Temperature

Pressure

Altitute

MQTT Protocol

InfluxDB

ESP32

Expansion Board or External Shield

Send data from esp32 to Node-RED

import network, time, ujson
from umqtt.simple import MQTTClient
from machine import Pin, I2C
from bmp280 import BMP280

SSID = "TP-LINK_56C612"
PASSWORD = "06941314"

BROKER = "test.mosquitto.org"
PORT = 1883
CLIENT_ID = b"esp32_bmp280_1"
TOPIC = b"/aupp/esp32/bmp280"
KEEPALIVE = 30


def wifi_connect():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print("Connecting to WiFi...")
        wlan.connect(SSID, PASSWORD)
        t0 = time.ticks_ms()
        while not wlan.isconnected():
            if time.ticks_diff(time.ticks_ms(), t0) > 20000:
                raise RuntimeError("Wi-Fi connect timeout")
            time.sleep(0.3)
    print("WiFi OK:", wlan.ifconfig())
    return wlan


def make_client():
    return MQTTClient(client_id=CLIENT_ID, server=BROKER, port=PORT, keepalive=KEEPALIVE)


def connect_mqtt(c):
    time.sleep(0.5)
    c.connect()
    print("MQTT connected")


def main():
    wifi_connect()

    # Setup BMP280
    i2c = I2C(0, scl=Pin(22), sda=Pin(21))
    sensor = BMP280(i2c)

    client = make_client()

    while True:
        try:
            connect_mqtt(client)
            while True:
                data = {
                    "temperature": round(sensor.temperature, 2),
                    "pressure": round(sensor.pressure / 100, 2),
                    "altitude": round(sensor.altitude, 2)
                }

                msg = ujson.dumps(data)
                client.publish(TOPIC, msg)

                print("Sent:", msg)
                time.sleep(5)

        except OSError as e:
            print("MQTT error:", e)
            try:
                client.close()
            except:
                pass
            print("Retrying MQTT in 3s...")
            time.sleep(3)


main()

Node-RED

Node-RED is a flow-based programming tool for connecting hardware devices, APIs, and online services.

  • Developed by IBM

  • Built on Node.js

  • Uses a browser-based visual editor

  • Designed especially for IoT applications

Node-RED works by connecting:

  • Nodes (blocks of functionality)

  • With wires

  • To create a flow

Node-RED

BMP280

Temperature

Pressure

Altitute

MQTT Protocol

ESP32

So, from here the esp32 are reading the sensor value and send it to the Node-RED

Node-RED

In the Node-RED Block, we receive the sensor topic /aupp/esp32/bmp280 and then send it to the influxDB

msg.measurement = "bmp280";

msg.payload = {
    temperature: Number(msg.payload.temperature),
    pressure: Number(msg.payload.pressure),
    altitude: Number(msg.payload.altitude)
};

return msg;

InfluxDB

BMP280

Temperature

Pressure

Altitute

MQTT Protocol

InfluxDB

ESP32

The Node-RED will send all the data to the influxDB

InfluxDB

In InfluxDB, We create a database name aupp_lab and the image below shows all of the sensor data

Grafana Dashboard

BMP280

Temperature

Pressure

Altitute

MQTT Protocol

InfluxDB

ESP32

Grafana Dashboard

Below is the Grafana dashboard where we get it from the InfluxDB

Gas Sensor 

Gas Sensor (MQ 5)

MQ-5 Gas Sensor used to detect:

  • LPG

  • Natural Gas

  • Methane

  • Hydrogen

MQ-5 Gas Sensor is commonly used in:

  • Gas leakage detection

  • Kitchen safety systems

  • Industrial safety systems

Gas Sensor (MQ 5)

This sensor can detect these gases when their concentrations are between 200 and 10,000 parts per million (ppm).

PPM stands for parts per million, and it’s a common unit used to measure the concentration of a specific gas in the air. It simply represents the ratio of how many gas molecules of one type exist among a million total gas molecules in the air. For example, if the air has 500 ppm of carbon monoxide, that means out of every 1,000,000 gas molecules, 500 are carbon monoxide, and the other 999,500 molecules are different gases.

CO Level (ppm) Effect
0–9 ppm Safe (normal air)
35 ppm Headache after 6–8 hours
100 ppm Headache, dizziness in 1–2 hours
400 ppm Life-threatening in 3 hours
800+ ppm Very dangerous

Gas Sensor (MQ 5)

MQ-5 does NOT directly give exact ppm.

It gives:

  • Voltage change

  • Resistance change

To know exact ppm:

  • Calibrate in clean air

  • Use datasheet curve

  • Compute Rs/R0

  • Map to ppm graph

Gas Sensor (MQ 5)

The analog output (from the AO pin) changes based on the concentration of gas. When there’s more gas in the air, the output voltage increases. When there’s less gas, the output voltage decreases

ESP32 ADC is 12 bits, so it means that it can read the value between 0 to 4095

If we want to know the voltage value from the gas sensor we can do:

V = \frac{ADC}{4095} \times 3.3

Connection

Coding and Test

Text

from machine import Pin, ADC
import time

# Configure ADC pin
mq5 = ADC(Pin(33))


mq5.atten(ADC.ATTN_11DB)      
mq5.width(ADC.WIDTH_12BIT)   

while True:
    gas_value = mq5.read()   
    print("Raw ADC Value:", gas_value)

    voltage = (gas_value / 4095) * 3.3
    print("Voltage: {:.2f} V".format(voltage))

    print("----------------------")
    time.sleep(1)

We see that the voltage is about 1.35V

MLX90614

MLX90614

MLX90614 is a non-contact infrared temperature sensor.

It measures:

  • Object temperature (non-contact): The temperature of an object in front of the sensor, measured using infrared radiation.
  • Ambient temperature: The temperature of the surrounding air around the sensor.

MLX90614 sees heat radiation.It is like a "heat camera" but single pixel.

MLX90614

The sensor:

  • Detects IR radiation

  • Converts it into voltage

  • Uses internal calibration

  • Outputs digital temperature via I2C

It does NOT measure air temperature directly. It measures thermal radiation from an object.

Connection

Coding and Testing

from machine import I2C

class MLX90614:
    def __init__(self, i2c, address=0x5A):
        self.i2c = i2c
        self.address = address

    def read16(self, reg):
        data = self.i2c.readfrom_mem(self.address, reg, 3)
        return data[0] | (data[1] << 8)

    def read_temp(self, reg):
        temp = self.read16(reg)
        return (temp * 0.02) - 273.15

    def read_ambient_temp(self):
        return self.read_temp(0x06)

    def read_object_temp(self):
        return self.read_temp(0x07)

This is the library. Save it as mlx90614.py in micropython device

from machine import Pin, I2C
import time
import mlx90614

i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000)

sensor = mlx90614.MLX90614(i2c)

while True:
    print("Ambient:", sensor.read_ambient_temp())
    print("Object :", sensor.read_object_temp())
    print("-----------------")
    time.sleep(1)

Code for Testing

DS3231 (Real Time Clock)

DS3231 (Real Time Clock)

It keeps track of Year, Month, Date, Day, Hour, Minute, and Second Even when power is OFF 

NTP (WiFi) DS3231
Needs Internet No Internet needed
Not good offline Works standalone
Time lost if WiFi fails Keeps time with battery

Why Use DS3231 Instead of NTP?

Connection

Coding and Testing

from machine import I2C

class DS3231:
    def __init__(self, i2c, addr=0x68):
        self.i2c = i2c
        self.addr = addr

    def bcd2dec(self, bcd):
        return (bcd >> 4) * 10 + (bcd & 0x0F)

    def dec2bcd(self, dec):
        return (dec // 10 << 4) + (dec % 10)

    def get_time(self):
        data = self.i2c.readfrom_mem(self.addr, 0x00, 7)
        second = self.bcd2dec(data[0])
        minute = self.bcd2dec(data[1])
        hour   = self.bcd2dec(data[2])
        day    = self.bcd2dec(data[4])
        month  = self.bcd2dec(data[5])
        year   = self.bcd2dec(data[6]) + 2000
        return (year, month, day, hour, minute, second)
    def set_time(self, year, month, day, hour, minute, second):
        year = year - 2000  # DS3231 stores year as 00–99
        
        data = bytearray(7)
        data[0] = self.dec2bcd(second)
        data[1] = self.dec2bcd(minute)
        data[2] = self.dec2bcd(hour)
        data[3] = self.dec2bcd(1)      # Day of week (1=Monday, simple use)
        data[4] = self.dec2bcd(day)
        data[5] = self.dec2bcd(month)
        data[6] = self.dec2bcd(year)

        self.i2c.writeto_mem(self.addr, 0x00, data)

Save this as ds3231.py in the micropython device

Coding and Testing

from machine import Pin, I2C
import ds3231
import time

i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000)
rtc = ds3231.DS3231(i2c)

# Set time: (Year, Month, Day, Hour, Minute, Second)
rtc.set_time(2026, 3, 2, 11, 24, 0)

print("Time Set!")

First, you need to set the time based on the current date and time.

from machine import Pin, I2C
import time
import ds3231

i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000)

rtc = ds3231.DS3231(i2c)

while True:
    now = rtc.get_time()
    print("Date: {}-{}-{}".format(now[0], now[1], now[2]))
    print("Time: {:02}:{:02}:{:02}".format(now[3], now[4], now[5]))
    print("----------------------")
    time.sleep(1)

Try unplugged the usb cable for 2mn from your laptop and plug it in again and run this code more if you still see the time is correct or not