You dont have javascript enabled! Please enable it!

Practical case: air quality alarm with Raspberry Pi 4

Practical case: air quality alarm with Raspberry Pi 4 — hero

Raspberry Pi 4 Air-Quality Alarm with BME680 and HyperPixel 4.0 Touch

Objective and use case

What you’ll build: A Raspberry Pi 4 Model B air-quality dashboard using a BME680 sensor and HyperPixel 4.0 Touch display to show live room conditions and estimate IAQ, eCO2, and TVOC in real time. The interface runs locally at smooth touchscreen refresh rates and raises a clear on-screen alarm when estimated eCO2 exceeds a configurable threshold, such as 1000 ppm.

Why it matters / Use cases

  • Classroom ventilation reminder that gives a visible prompt when estimated eCO2 rises above 1000–1500 ppm.
  • Home office comfort monitor for tracking temperature, humidity, and stale-air trends with low-latency local updates.
  • Workshop or hobby room indicator to spot poor air refresh and changing VOC patterns during indoor projects.
  • Meeting room readiness display with color-coded status for quick go/no-go checks before occupancy.
  • Educational prototype for comparing ventilation changes against estimated IAQ metrics and alarm behavior.

Expected outcome

  • Touchscreen UI showing temperature, humidity, pressure, gas resistance, IAQ score, estimated eCO2, and estimated TVOC.
  • Color-coded room status with responsive rendering, typically around 20–30 FPS on a Pi 4 for a lightweight dashboard.
  • Visible alarm banner when estimated eCO2 crosses the configured limit, with sub-second local UI response.
  • Touch or mouse interaction for toggling details and temporarily acknowledging the alarm without stopping monitoring.
  • Support for both real hardware mode and mock mode for testing UI flow, alarm thresholds, and demo data.
  • Efficient local operation suitable for kiosk-style use, often staying in a modest GPU range for a simple full-screen dashboard.

Audience: Raspberry Pi makers, educators, and hobbyists building practical environmental displays; Level: Intermediate

Architecture/flow: The Pi 4 reads BME680 sensor values, derives IAQ plus estimated eCO2/TVOC, updates a full-screen HyperPixel Touch dashboard, applies color status rules, and triggers a visible alarm banner with optional touch acknowledge when the configured threshold is exceeded; in mock mode, simulated readings follow the same pipeline.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 31 sections, 1 tables and 23 code blocks detected in the published content.
  • Checked code: 2 Python/py_compile, 20 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo validation-capable device profiles; unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

Safety note

This project is an educational indoor air-quality prototype, not a certified safety instrument.

  • The BME680 does not directly measure true CO2.
  • The displayed eCO2 and IAQ values in this tutorial are heuristic estimates derived from humidity and gas-resistance behavior.
  • Do not use this build as the sole basis for health decisions, legal compliance, hazardous-atmosphere detection, or emergency response.
  • Power the Raspberry Pi only from a suitable low-voltage USB-C supply.
  • Use a case or safe mounting so exposed electronics cannot short or be touched accidentally.

Prerequisites

You need:

  • Raspberry Pi 4 Model B
  • Raspberry Pi OS Bookworm 64-bit
  • Python 3.11
  • BME680 breakout with I2C support
  • HyperPixel 4.0 Touch
  • Basic terminal usage
  • Basic GPIO/I2C wiring familiarity

Materials

ItemExact modelPurpose
Single-board computerRaspberry Pi 4 Model BRuns the application
SensorBME680Temperature, humidity, pressure, gas resistance
Touch displayHyperPixel 4.0 TouchLocal touchscreen UI
Power supply5 V USB-C supply for Raspberry Pi 4Stable power
WiringFemale-to-female jumper wiresSensor wiring
StoragemicroSD card, 16 GB or largerOS and project files

Hardware setup

BME680 I2C wiring

Connect the BME680 to the Raspberry Pi 40-pin header:

  • BME680 VCC/VIN -> 3.3 V on Raspberry Pi, physical pin 1
  • BME680 GND -> GND on Raspberry Pi, physical pin 6
  • BME680 SDA -> SDA1 on Raspberry Pi, physical pin 3
  • BME680 SCL -> SCL1 on Raspberry Pi, physical pin 5

Notes:

  • Confirm your breakout board is 3.3 V-safe
  • Do not connect a 3.3 V-only board to 5 V
  • If the board also supports SPI, use only the I2C pins listed above

HyperPixel 4.0 Touch

Install and verify the HyperPixel using the current Pimoroni instructions for your Raspberry Pi OS image. Before continuing, confirm:

  • The display shows the Raspberry Pi desktop
  • Touch input works correctly

System preparation

Update the Pi and enable I2C:

sudo apt update
sudo apt full-upgrade -y
sudo raspi-config

In raspi-config:

  1. Open Interface Options
  2. Enable I2C
  3. Reboot if prompted

Check Python:

python3 --version

Install required system packages:

sudo apt install -y git python3-pip python3-venv i2c-tools

Verify the sensor appears on I2C:

i2cdetect -y 1

A BME680 commonly appears at 0x76 or 0x77.

Project setup

Create the project and virtual environment:

mkdir -p ~/projects/touchscreen-co2-air-quality-alarm
cd ~/projects/touchscreen-co2-air-quality-alarm
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install pygame smbus2 bme680

Validate imports:

python3 -c "import pygame, smbus2, bme680; print('imports ok')"

Application code

air_quality_alarm.py

#!/usr/bin/env python3
"""
Touchscreen air-quality alarm for:
- Raspberry Pi 4 Model B
- BME680 over I2C
- HyperPixel 4.0 Touch

The IAQ, eCO2, and TVOC values here are educational heuristic estimates.
They are useful for trend display and UI behavior validation, not certified measurement.
"""

from __future__ import annotations

import argparse
import math
import random
import sys
import time
from dataclasses import dataclass
from typing import Optional, Tuple

import pygame


@dataclass
class SensorReading:
    temperature_c: float
    humidity_pct: float
    pressure_hpa: float
    gas_ohms: float
    iaq_score: float
    eco2_ppm: int
    tvoc_ppb: int
    timestamp: float


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def estimate_iaq_score(humidity_pct: float, gas_ohms: float) -> float:
    """
    Educational heuristic only.
    Lower score is better, with an approximate 0..500 scale.
    """
    humidity_target = 40.0
    humidity_offset = abs(humidity_pct - humidity_target)
    humidity_score = clamp(humidity_offset / 40.0 * 25.0, 0.0, 25.0)

    gas_reference = 50000.0
    gas_ratio = clamp((gas_reference - gas_ohms) / gas_reference, 0.0, 1.0)
    gas_score = gas_ratio * 475.0

    return round(humidity_score + gas_score, 1)


def estimate_eco2_ppm(iaq_score: float, gas_ohms: float) -> int:
    """
    Educational heuristic only.
    Maps worsening gas behavior to an estimated eCO2 range.
    """
    base = 420
    score_component = int(iaq_score * 4.2)
    gas_component = int(clamp((50000.0 - gas_ohms) / 80.0, 0.0, 2000.0))
    return int(clamp(base + score_component + gas_component, 400, 2500))


def estimate_tvoc_ppb(iaq_score: float, gas_ohms: float) -> int:
    """
    Educational heuristic only.
    """
    base = 20
    score_component = int(iaq_score * 1.5)
    gas_component = int(clamp((50000.0 - gas_ohms) / 120.0, 0.0, 1000.0))
    return int(clamp(base + score_component + gas_component, 0, 1200))


def air_quality_state(eco2_ppm: int) -> Tuple[str, Tuple[int, int, int]]:
    if eco2_ppm < 800:
        return ("GOOD", (40, 140, 70))
    if eco2_ppm < 1200:
        return ("ELEVATED", (180, 150, 40))
    if eco2_ppm < 1600:
        return ("POOR", (190, 100, 30))
    return ("ALARM", (170, 40, 40))


class BME680Adapter:
    """Real sensor adapter using the bme680 package."""

    def __init__(self, i2c_addr: int = 0x76):
        self.i2c_addr = i2c_addr
        self.sensor = None

    def open(self) -> None:
        import bme680

        self.sensor = bme680.BME680(i2c_addr=self.i2c_addr)
        self.sensor.set_humidity_oversample(bme680.OS_2X)
        self.sensor.set_pressure_oversample(bme680.OS_4X)
        self.sensor.set_temperature_oversample(bme680.OS_8X)
        self.sensor.set_filter(bme680.FILTER_SIZE_3)
        self.sensor.set_gas_status(bme680.ENABLE_GAS_MEAS)
        self.sensor.set_gas_heater_temperature(320)
        self.sensor.set_gas_heater_duration(150)
        self.sensor.select_gas_heater_profile(0)

    def read(self) -> SensorReading:
        if self.sensor is None:
            raise RuntimeError("Sensor not opened")

        if not self.sensor.get_sensor_data():
            raise RuntimeError("No sensor data available")

        data = self.sensor.data
        gas_ohms = float(getattr(data, "gas_resistance", 0.0) or 0.0)

        iaq_score = estimate_iaq_score(float(data.humidity), gas_ohms)
        eco2_ppm = estimate_eco2_ppm(iaq_score, gas_ohms)
        tvoc_ppb = estimate_tvoc_ppb(iaq_score, gas_ohms)

        return SensorReading(
            temperature_c=float(data.temperature),
            humidity_pct=float(data.humidity),
            pressure_hpa=float(data.pressure),
            gas_ohms=gas_ohms,
            iaq_score=iaq_score,
            eco2_ppm=eco2_ppm,
            tvoc_ppb=tvoc_ppb,
            timestamp=time.time(),
        )


class MockBME680Adapter:
    """Mock adapter for validation without hardware."""

    def __init__(self) -> None:
        self.t0 = time.time()

    def open(self) -> None:
        return

    def read(self) -> SensorReading:
        elapsed = time.time() - self.t0

        temperature_c = 22.0 + 1.5 * math.sin(elapsed / 40.0)
        humidity_pct = 45.0 + 8.0 * math.sin(elapsed / 55.0)
        pressure_hpa = 1012.0 + 1.2 * math.sin(elapsed / 120.0)

        baseline = 45000.0
        drop = min(32000.0, elapsed * 350.0)
        noise = random.uniform(-1200.0, 1200.0)
        gas_ohms = max(5000.0, baseline - drop + noise)

        iaq_score = estimate_iaq_score(humidity_pct, gas_ohms)
        eco2_ppm = estimate_eco2_ppm(iaq_score, gas_ohms)
        tvoc_ppb = estimate_tvoc_ppb(iaq_score, gas_ohms)

        return SensorReading(
            temperature_c=temperature_c,
            humidity_pct=humidity_pct,
            pressure_hpa=pressure_hpa,
            gas_ohms=gas_ohms,
            iaq_score=iaq_score,
            eco2_ppm=eco2_ppm,
            tvoc_ppb=tvoc_ppb,
            timestamp=time.time(),
        )


class HyperPixelUI:
    def __init__(self, width: int = 800, height: int = 480):
        pygame.init()
        pygame.font.init()
        self.screen = pygame.display.set_mode((width, height))
        pygame.display.set_caption("Air Quality Alarm")
        self.width = width
        self.height = height
        self.font_big = pygame.font.SysFont("Arial", 52)
        self.font_med = pygame.font.SysFont("Arial", 32)
        self.font_small = pygame.font.SysFont("Arial", 24)
        self.show_detail = False
        self.alarm_ack_until = 0.0

    def draw(self, reading: SensorReading, alarm_threshold: int) -> None:
        state, bg = air_quality_state(reading.eco2_ppm)
        self.screen.fill(bg)

        now = time.time()
        alarm_active = (
            reading.eco2_ppm >= alarm_threshold and now >= self.alarm_ack_until
        )

        title = self.font_big.render(state, True, (255, 255, 255))
        self.screen.blit(title, (30, 20))

        eco2_text = self.font_big.render(
            f"eCO2 {reading.eco2_ppm} ppm", True, (255, 255, 255)
        )
        self.screen.blit(eco2_text, (30, 95))

        iaq_text = self.font_med.render(
            f"IAQ score: {reading.iaq_score:.1f}", True, (255, 255, 255)
        )
        self.screen.blit(iaq_text, (30, 170))

        temp_text = self.font_small.render(
            f"Temp: {reading.temperature_c:.1f} C", True, (255, 255, 255)
        )
        hum_text = self.font_small.render(
            f"Humidity: {reading.humidity_pct:.1f} %", True, (255, 255, 255)
        )
        pres_text = self.font_small.render(
            f"Pressure: {reading.pressure_hpa:.1f} hPa", True, (255, 255, 255)
        )
        gas_text = self.font_small.render(
            f"Gas: {reading.gas_ohms:.0f} ohms", True, (255, 255, 255)
        )
        tvoc_text = self.font_small.render(
            f"TVOC est: {reading.tvoc_ppb} ppb", True, (255, 255, 255)
        )

        self.screen.blit(temp_text, (30, 230))
        self.screen.blit(hum_text, (30, 265))
        self.screen.blit(pres_text, (30, 300))

        if self.show_detail:
            self.screen.blit(gas_text, (30, 335))
            self.screen.blit(tvoc_text, (30, 370))

        info = self.font_small.render(
            "Tap left: details  |  Tap right: acknowledge 60 s",
            True,
            (255, 255, 255),
        )
        self.screen.blit(info, (20, 440))

        if alarm_active:
            banner = pygame.Rect(500, 20, 260, 90)
            pygame.draw.rect(self.screen, (255, 230, 230), banner, border_radius=12)
            msg1 = self.font_med.render("VENTILATE ROOM", True, (120, 0, 0))
            msg2 = self.font_small.render(
                f"Threshold {alarm_threshold} ppm exceeded", True, (120, 0, 0)
            )
            self.screen.blit(msg1, (520, 35))
            self.screen.blit(msg2, (520, 75))

        pygame.display.flip()

    def handle_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.MOUSEBUTTONDOWN:
            x, _y = event.pos
            if x < self.width // 2:
                self.show_detail = not self.show_detail
            else:
                self.alarm_ack_until = time.time() + 60.0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Touchscreen CO2 air-quality alarm")
    parser.add_argument("--mock", action="store_true", help="Run without hardware")
    parser.add_argument(
        "--i2c-addr",
        default="0x76",
        help="BME680 I2C address such as 0x76 or 0x77",
    )
    parser.add_argument(
        "--alarm-ppm",
        type=int,
        default=1200,
        help="Estimated eCO2 alarm threshold",
    )
    parser.add_argument(
        "--interval",
        type=float,
        default=2.0,
        help="Seconds between sensor updates",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    i2c_addr = int(args.i2c_addr, 16)

    sensor = MockBME680Adapter() if args.mock else BME680Adapter(i2c_addr=i2c_addr)
    sensor.open()

    ui = HyperPixelUI()
    last_read = 0.0
    reading: Optional[SensorReading] = None
    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            ui.handle_event(event)

        now = time.time()
        if reading is None or (now - last_read) >= args.interval:
            reading = sensor.read()
            last_read = now

        if reading is not None:
            ui.draw(reading, args.alarm_ppm)

        time.sleep(0.05)

    pygame.quit()
    return 0


if __name__ == "__main__":
    sys.exit(main())

test_logic.py

#!/usr/bin/env python3

import air_quality_alarm as app


def run() -> None:
    assert app.clamp(5, 0, 10) == 5
    assert app.clamp(-1, 0, 10) == 0
    assert app.clamp(11, 0, 10) == 10

    iaq_good = app.estimate_iaq_score(40.0, 50000.0)
    iaq_bad = app.estimate_iaq_score(70.0, 10000.0)
    assert iaq_good <= iaq_bad

    eco2_good = app.estimate_eco2_ppm(iaq_good, 50000.0)
    eco2_bad = app.estimate_eco2_ppm(iaq_bad, 10000.0)
    assert eco2_good < eco2_bad

    assert app.air_quality_state(700)[0] == "GOOD"
    assert app.air_quality_state(900)[0] == "ELEVATED"
    assert app.air_quality_state(1300)[0] == "POOR"
    assert app.air_quality_state(1800)[0] == "ALARM"

    mock = app.MockBME680Adapter()
    mock.open()
    reading = mock.read()
    assert 0 <= reading.iaq_score <= 500
    assert 400 <= reading.eco2_ppm <= 2500
    assert reading.pressure_hpa > 800

    print("logic tests passed")


if __name__ == "__main__":
    run()

Save the files

cd ~/projects/touchscreen-co2-air-quality-alarm
chmod 700 .
nano air_quality_alarm.py
nano test_logic.py
chmod +x air_quality_alarm.py test_logic.py

Validation steps

1. Validate syntax

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 -m py_compile air_quality_alarm.py test_logic.py

Expected evidence:

  • No output from py_compile

2. Validate logic tests

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 test_logic.py

Expected evidence:

logic tests passed

3. Validate mock mode

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --mock --alarm-ppm 1200 --interval 1.5

Expected evidence:

  • A window opens at approximately 800 x 480
  • Values update every few seconds
  • The background color changes as the simulated room worsens
  • Clicking the left half toggles details
  • Clicking the right half acknowledges the alarm for 60 seconds
  • After enough simulated time, the VENTILATE ROOM banner appears

4. Validate real sensor mode

If the sensor is at 0x76:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --alarm-ppm 1200 --interval 2.0

If the sensor is at 0x77:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --i2c-addr 0x77 --alarm-ppm 1200 --interval 2.0

Expected evidence:

  • Temperature is plausible for the room
  • Humidity is plausible for the room
  • Pressure is in a normal atmospheric range
  • Gas resistance is non-zero
  • The touchscreen updates continuously

5. Practical room validation

To validate the tutorial’s main objective, test the device in a real room:

  1. Put the device in a small enclosed room
  2. Leave the door and windows closed for a period
  3. Observe whether the estimated eCO2 trend rises over time
  4. Open a door or window
  5. Observe whether the displayed trend begins to improve over subsequent updates

Expected evidence:

  • The status is easy to interpret on screen
  • The visible alarm appears when the configured estimated threshold is exceeded
  • Ventilation changes produce a visible trend change

This validates the prototype as a trend-based educational room reminder, not as a certified CO2 meter.

Run commands

Mock mode:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --mock --alarm-ppm 1200 --interval 1.5

Real hardware mode with default address:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --alarm-ppm 1200 --interval 2.0

Real hardware mode with alternate address:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --i2c-addr 0x77 --alarm-ppm 1200 --interval 2.0

Optional launcher script

cat > ~/projects/touchscreen-co2-air-quality-alarm/run.sh << 'EOF'
#!/bin/sh
set -eu
cd "$HOME/projects/touchscreen-co2-air-quality-alarm"
. .venv/bin/activate
exec python3 air_quality_alarm.py --alarm-ppm 1200 --interval 2.0
EOF
chmod +x ~/projects/touchscreen-co2-air-quality-alarm/run.sh

Troubleshooting

Sensor not visible in i2cdetect

Recheck:

  • I2C enabled in raspi-config
  • Correct 3.3 V power
  • SDA and SCL not swapped
  • Solid ground connection
  • Address 0x76 vs 0x77

Useful commands:

i2cdetect -y 1
python3 air_quality_alarm.py --i2c-addr 0x77

Import errors

Activate the virtual environment and reinstall:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 -m pip install pygame smbus2 bme680

UI does not appear on HyperPixel

Check:

  • The HyperPixel is the active desktop display
  • You are running from a local desktop session, not a headless SSH shell
  • The display and touch drivers are correctly installed

Values look unrealistic

Possible reasons:

  • Sensor warm-up time
  • Direct drafts or body heat nearby
  • Aggressive threshold for your room

Try a higher alarm threshold:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --alarm-ppm 1400 --interval 2.0

For stricter behavior:

cd ~/projects/touchscreen-co2-air-quality-alarm
. .venv/bin/activate
python3 air_quality_alarm.py --alarm-ppm 1000 --interval 2.0

Final checklist

  • [ ] Raspberry Pi 4 Model B is running Raspberry Pi OS Bookworm 64-bit
  • [ ] Python 3.11 is installed
  • [ ] HyperPixel 4.0 Touch displays the desktop and accepts touch
  • [ ] BME680 is wired to 3.3 V, GND, SDA, and SCL correctly
  • [ ] i2cdetect -y 1 shows the sensor at 0x76 or 0x77
  • [ ] Virtual environment is created
  • [ ] Dependencies install successfully
  • [ ] python3 -m py_compile air_quality_alarm.py test_logic.py succeeds
  • [ ] python3 test_logic.py prints logic tests passed
  • [ ] Mock mode opens the UI and shows changing values
  • [ ] Left-side touch toggles detailed metrics
  • [ ] Right-side touch acknowledges the alarm for 60 seconds
  • [ ] Real mode shows live sensor values
  • [ ] The visible alarm appears when the estimated threshold is crossed
  • [ ] Ventilation changes cause a visible trend change

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What Raspberry Pi model is used in this air-quality dashboard project?




Question 2: Which sensor is utilized to measure the room conditions and air quality?




Question 3: What specific display is used for the dashboard interface?




Question 4: At what estimated eCO2 level does the classroom ventilation reminder typically give a visible prompt?




Question 5: Which of the following is a mentioned use case for this project?




Question 6: Which of the following metrics is NOT explicitly listed as being shown on the touchscreen UI?




Question 7: What happens when the estimated eCO2 exceeds the configurable threshold?




Question 8: What does the workshop or hobby room indicator help spot?




Question 9: What type of refresh rates does the interface run at locally?




Question 10: What is the mentioned use case for the meeting room readiness display?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top