# Project Scentinel: Smart Air Quality Monitor & Deodorizer ***Author:** Isak Jarrin **Student ID:** ij222ra **Linnaeus University** **Project for Course 1DT305*** --- ## Short Project Overview This tutorial details how to build "Project Scentinel," an IoT device that converts a standard Glade automatic air freshener into a smart, data-driven air quality monitoring and deodorizing system. It uses a Raspberry Pi Pico W to read environmental data from a BME680 sensor. Based on user commands or air quality levels, it can automatically trigger the freshener, provide multi-sensory alerts, and send data to a self-hosted Home Assistant dashboard for real-time and historical analysis. This project offers a proactive and intelligent alternative to traditional time-based air fresheners. **Estimated Time to Complete:** 8-12 hours (depending on soldering and coding experience). --- ## Objective ### Why you chose the project The project was chosen to address the significant limitations of conventional automatic air fresheners. Standard fresheners work on simple timers or motion detection, with no awareness of the actual air quality. This results in wasted fragrance, and more importantly, it fails to address the underlying air pollution issues in a room. ### What purpose does it serve Project Scentinel serves as a proactive air quality management system. Its primary purpose is to: * **Monitor Key Pollutants:** It actively tracks Volatile Organic Compounds (VOCs), temperature, and humidity. * **Automate Intelligently:** It triggers the deodorizing spray only when air quality drops below a certain threshold, making it more efficient and responsive than a simple timer. * **Provide Data-Driven Insights:** By logging air quality data to a dashboard, users can understand their indoor environment's patterns and identify potential pollution sources. * **Offer User Interaction:** A physical button and multi-sensory feedback (LEDs and audio) allow for direct, on-demand control. ### What insights you think it will give The data gathered by Project Scentinel can provide valuable new insights into a user's living or working space. By visualizing historical data, a user can correlate poor air quality spikes with specific activities (e.g., cooking, cleaning, or times of high occupancy). This empowers the user to take informed actions, such as improving ventilation during certain hours or identifying products that release high levels of VOCs, ultimately leading to a healthier indoor environment. --- ## A Note on Project Development This document describes the final, successful version of Project Scentinel, which uses a self-hosted **Home Assistant** server with an **MQTT broker** for the backend data platform. It's important to note that the project began with a simpler proof-of-concept. This initial version was built successfully using the Adafruit IO cloud platform but lacked the local control, advanced hardware integration, and robust user feedback of the final version. This report focuses exclusively on the architecture of the final, completed project. --- ## Material The following materials are required for the final version of this project. | Component | Purpose | Est. Price (SEK) | | :--- | :--- | :--- | | Raspberry Pi Pico W | Main microcontroller with Wi-Fi. | 105 kr | | Glade Automatic Air Freshener | Provides the casing and DC motor actuator. | 105 kr | | BME680 Sensor Breakout | Detects VOCs, temperature, and humidity. | 160 kr | | Adafruit DRV8833 Motor Driver | Safely controls the high-current DC motor. | 55 kr | | Adafruit PAM8302 Audio Amp | Amplifies audio signals for the speaker. | 45 kr | | Small 8 Ohm Speaker | Provides audio alerts and cues. | 25 kr | | Common Cathode RGB LED | A visual status indicator for air quality. | 15 kr | | Push Button | For on-demand user input. | 10 kr | | USB Cable & 5V Adapter | Provides continuous power to the Pico W. | 55 kr | | Resistors, Wires, etc. | For connections and current limiting. | 55 kr | --- ## Computer Setup The device itself is programmed using MicroPython and the Thonny IDE. The backend server runs Home Assistant OS on a dedicated low-power PC stick. ### Pico Setup: 1. **Install Thonny IDE:** Download and install Thonny from its official website. 2. **Flash MicroPython Firmware:** * Download the latest MicroPython `.uf2` file for the "Raspberry Pi Pico W". * Hold the **BOOTSEL** button on your Pico W while plugging it into your computer. It will appear as a drive named `RPI-RP2`. * Drag and drop the `.uf2` file onto the drive. It will reboot automatically. 3. **Connect in Thonny:** * In Thonny, go to **Tools > Options > Interpreter**, select `MicroPython (Raspberry Pi Pico)` and the correct COM port. You should see the REPL prompt (`>>>`). ### Server (Home Assistant) Setup: A key challenge was installing the 64-bit Home Assistant OS on a PC stick with a 32-bit UEFI firmware. The following method proved successful. 1. **Create a Bootable Installer:** * Use the **Rufus** tool to create a bootable SD card from a **Lubuntu 24.04.2 LTS (64-bit) ISO**. The partition scheme must be **GPT**. * Download the 32-bit UEFI bootloader file, `bootia32.efi`. * Manually copy `bootia32.efi` into the `EFI/BOOT/` directory on the newly created SD card. 2. **Flash Home Assistant OS:** * Boot the PC stick from the SD card using the "Boot From File" option in the BIOS/UEFI, manually selecting the `bootia32.efi` file. * From the Lubuntu live environment, download the **Home Assistant OS Generic x86-64** image (`.img.xz` file). * Use the `lsblk` command in a terminal to identify the internal drive (e.g., `/dev/mmcblk1`). * Write the image to the internal drive using the command: ```bash xzcat haos_generic-x86-64-*.img.xz | sudo dd of=/dev/mmcblk1 status=progress ``` 3. **Configure MQTT:** * **Install Broker:** In Home Assistant (**Settings > Add-ons > Add-on Store**), install and start the **Mosquitto broker** add-on. * **Create User:** In **Settings > People & Zones > Users**, create a new, non-administrator user for MQTT. * **Enable Integration:** In **Settings > Devices & Services**, the MQTT integration will be auto-discovered. Click **Configure** and **Submit**. --- ## Putting Everything Together All components should first be tested on a breadboard before final assembly. The core idea is to have two separate power circuits to prevent the motor's high current draw from affecting the microcontroller. The Pico W and its sensors are powered via USB, while the motor is powered by AA batteries. ![Components](https://hackmd.io/_uploads/B1v9MA2rel.png) **Fig. 1. Circuit diagram for Project Scentinel.** ### Wiring Connections (Final Version): * **BME680 Sensor (I2C):** VCC → 3V3_OUT, GND → GND, SDA → GP0, SCL → GP1. * **DRV8833 Motor Driver:** VMOT → Battery +, GND → Battery - AND Pico GND, AIN1/AIN2 → GP15/GP14, nSLEEP -> GP13. * **PAM8302 Audio Amp:** Vin → VBUS (5V), GND → GND, A+ → GP18, SD -> GP19. * **RGB LED (Common Cathode):** Common Cathode (longest leg) → GND, R/G/B pins → Resistors → GP12/GP11/GP10. * **Button:** One terminal → GP16, Other terminal → GND. --- ## Platform The platform for this project is a self-hosted Home Assistant server, which provides a robust, private, and highly extensible environment for IoT device management. * **Home Assistant:** An open-source home automation platform that acts as the central control system. * **MQTT (Message Queuing Telemetry Transport):** The communication protocol used between the Pico W and the server. The Pico "publishes" data to topics, and Home Assistant "subscribes" to those topics to receive the data. * **Mosquitto:** A lightweight and reliable MQTT broker that runs as an add-on inside Home Assistant, managing all MQTT message traffic. This stack was chosen for its local-first control, ensuring privacy and fast response times without reliance on cloud services. --- ## The Code The device firmware consists of three main files stored on the Raspberry Pi Pico W: `secrets.py` for credentials, `boot.py` for network connection, and `main.py` for the core application logic. The `bme680.py` library is also required. The code implements a state machine protocol to handle different operational modes (e.g., Idle, Manual Spray, Scanning) and provides clear user feedback through the LED and speaker for each state transition. *Find the complete, final code for `secrets.py`, `boot.py`, and `main.py` in the appendix of this report.* --- ## Transmitting the Data / Connectivity * **How often is the data sent?** The data is sampled from the sensor and transmitted via MQTT every 10-15 seconds. * **Which wireless and transport protocols were used?** * **Wireless Protocol:** Wi-Fi (802.11n), using the built-in module on the Raspberry Pi Pico W. * **Transport Protocol:** MQTT. Data is published to specific topics (e.g., `scentinel/sensor/temperature`). ### Design Choices Elaboration Wi-Fi is used for its easy integration into a home network. MQTT is the standard for IoT due to its efficiency and reliability. Its publish/subscribe model decouples the sensor from the backend; the Pico simply publishes data, and it doesn't need to know what is listening. This makes the system resilient to server reboots. --- ## Presenting the Data Data presentation is handled entirely by the Home Assistant dashboard. ### How is the dashboard built? 1. **Define MQTT Sensors:** In Home Assistant's `configuration.yaml` file, sensor entities are defined to listen to the specific MQTT topics that the Pico is publishing to. 2. **Create Dashboard & Cards:** In the Home Assistant UI, a new dashboard is created. "Cards" are added to visualize the data from the sensor entities. 3. **Visualize:** Different card types are used for different data. For example, the **Entities Card** provides a simple list of all current values, while the **Gauge Card** can be used for the Air Quality score, and the **History Graph Card** can show temperature trends over time. ![Screenshot 2025-07-08 125515](https://hackmd.io/_uploads/Hk9q7A3rlg.png) **Fig. 2. The Project Scentinel Home Assistant dashboard.** --- ## Data Storage and Automation * **Database:** Home Assistant has a built-in database (Recorder) that automatically stores the history of all sensor entities. This data is used to generate history graphs. * **Data Preservation:** The retention period for data is configurable within Home Assistant, allowing for long-term storage and analysis. * **Automation/Triggers:** The true power of the platform is realized through automations. For example, an automation can be created in Home Assistant to send a notification to a phone if the "Scentinel Air Quality" sensor drops below a certain percentage for more than 10 minutes, suggesting it's time to open a window. --- ## Finalizing the Design The final step is to move the tested circuit from the breadboard into the Glade air freshener's plastic casing. This requires careful planning and soldering. To achieve a compact profile, header pins can be desoldered from the modules, with wires soldered directly. Components should be secured inside with hot glue or double-sided tape. ![WhatsApp Image 2025-07-10 at 11.30.59_0719a38e](https://hackmd.io/_uploads/S1e9O-TBge.jpg) ![WhatsApp Image 2025-07-10 at 11.30.59_ad2e1dc1](https://hackmd.io/_uploads/S1g9_-Trll.jpg) **Fig. 3. The final integrated Project Scentinel.** --- ## Final Thoughts and Improvements This project is an excellent example of iterative development, moving from a cloud-based proof-of-concept to a more robust, self-hosted final product. The challenges of physical integration, firmware debugging, and server setup are key learning opportunities in any IoT project. ### What could be done better? * **Add Particulate Sensing:** Integrate a PM2.5 sensor (e.g., a PMS5003) for a more complete air quality profile. * **Refill Detection:** Add a sensor (e.g., a simple switch or distance sensor) to detect when the fragrance canister is empty and send an alert. * **Smarter Automation:** Use machine learning on the server to predict air quality changes and trigger actions proactively. * **Deeper Smart Home Integration:** Use the Home Assistant integration to allow for voice control (e.g., "Hey Google, activate the air freshener"). --- ## Appendix: Device Firmware Code ### A.1. secrets.py This file stores all credentials and calibration values. ``` # secrets.py WIFI_SSID = "Your_WiFi_Network_Name" WIFI_PASS = "Your_WiFi_Password" MQTT_BROKER = "IP_Address_Of_Home_Assistant" MQTT_CLIENT_ID = "pico_scentinel" MQTT_USER = "your_mqtt_username" MQTT_PASS = "your_mqtt_password" SEA_LEVEL_PRESSURE = 1013.25 GAS_BASELINE = 50000 ``` ### A.2. boot.py This script runs first to establish the network connection. ``` # boot.py import network import time import secrets print("--- Running boot.py ---") wlan = network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): print(f"Connecting to Wi-Fi: {secrets.WIFI_SSID}...") wlan.connect(secrets.WIFI_SSID, secrets.WIFI_PASS) max_wait = 15 while max_wait > 0: if wlan.isconnected(): break max_wait -= 1 print("...waiting for connection") time.sleep(1) if wlan.isconnected(): status = wlan.ifconfig() print(f"--- Wi-Fi Connected ---\nIP Address: {status[0]}") else: print("!!! Wi-Fi connection failed!") ``` ### A.3. main.py This is the final, complete application code that implements the state machine protocol. ``` # main.py - Project Scentinel Final Code import time import secrets from machine import Pin, I2C, PWM import network import bme680 from umqtt.simple import MQTTClient import ustruct import micropython print("\n--- Running main.py ---") print("--- Project Scentinel ---") # Hardware & Timing Configuration SCL_PIN, SDA_PIN = 1, 0 RED_PIN_NUM, GREEN_PIN_NUM, BLUE_PIN_NUM = 12, 11, 10 AIN1_PIN, AIN2_PIN, SLEEP_PIN = 15, 14, 13 AUDIO_PIN_NUM, SHUTDOWN_PIN_NUM, BUTTON_PIN_NUM = 18, 19, 16 AIR_QUALITY_THRESHOLD = 85 PUBLISH_INTERVAL_S = 10 DEBOUNCE_MS, DOUBLE_PRESS_MS = 50, 400 VOLUME_PERCENT, SPEED_CORRECTION = 100, 12 FORWARD_DURATION_S, BACKWARD_DURATION_S = 0.5, 0.5 SPEED_PERCENTAGE = 100 # Hardware Initialization print("Initializing hardware...") red_led = PWM(Pin(RED_PIN_NUM)); red_led.freq(1000) green_led = PWM(Pin(GREEN_PIN_NUM)); green_led.freq(1000) blue_led = PWM(Pin(BLUE_PIN_NUM)); blue_led.freq(1000) audio_shutdown_pin = Pin(SHUTDOWN_PIN_NUM, Pin.OUT); audio_shutdown_pin.value(0) button = Pin(BUTTON_PIN_NUM, Pin.IN, Pin.PULL_UP) ain1_pin_obj = Pin(AIN1_PIN, Pin.OUT) ain2_pin_obj = Pin(AIN2_PIN, Pin.OUT) ain1_pwm = PWM(ain1_pin_obj); ain1_pwm.freq(1000) ain2_pwm = PWM(ain2_pin_obj); ain2_pwm.freq(1000) sleep_pin = Pin(SLEEP_PIN, Pin.OUT); sleep_pin.high() MOTOR_SPEED_DUTY = int(65535 * (SPEED_PERCENTAGE / 100)) print("Motor Driver Initialized.") try: i2c = I2C(0, scl=Pin(SCL_PIN), sda=Pin(SDA_PIN)) bme = bme680.BME680_I2C(i2c=i2c) print("BME680 Sensor Initialized.") except Exception as e: print(f"!!! BME680 Init Failed: {e}") play_audio("error.wav") while True: set_color(255, 0, 0); time.sleep(0.5); set_color(0, 0, 0); time.sleep(0.5) # Helper Functions def set_color(r, g, b): red_led.duty_u16(r * 257) green_led.duty_u16(g * 257) blue_led.duty_u16(b * 257) def motor_forward(): ain1_pwm.duty_u16(MOTOR_SPEED_DUTY); ain2_pwm.duty_u16(0) def motor_backward(): ain1_pwm.duty_u16(0); ain2_pwm.duty_u16(MOTOR_SPEED_DUTY) def motor_stop(): ain1_pwm.duty_u16(0); ain2_pwm.duty_u16(0) def spray_cycle(): print("Action: Activating spray cycle.") motor_forward(); time.sleep(FORWARD_DURATION_S) motor_backward(); time.sleep(BACKWARD_DURATION_S) motor_stop(); print("Action: Spray cycle complete.") @micropython.native def play_audio(filename): print(f"--- Playback: '{filename}' ---") audio_shutdown_pin.value(1); time.sleep(0.1) pwm = None try: with open(filename, 'rb') as wav_file: header = wav_file.read(44) sample_rate = ustruct.unpack('<L', header[24:28])[0] bits_per_sample = ustruct.unpack('<H', header[34:36])[0] if bits_per_sample != 8: return ideal_delay_us = 1000000 // sample_rate actual_delay_us = max(1, ideal_delay_us - SPEED_CORRECTION) pwm = PWM(Pin(AUDIO_PIN_NUM)) pwm.freq(sample_rate * 2) buffer = bytearray(1024) while True: bytes_read = wav_file.readinto(buffer) if bytes_read == 0: break for i in range(bytes_read): sample = buffer[i] sample_signed = sample - 128 sample_amplified = (sample_signed * VOLUME_PERCENT) // 100 final_sample = max(0, min(255, sample_amplified + 128)) pwm.duty_u16(final_sample << 8) time.sleep_us(actual_delay_us) except Exception as e: print(f"ERROR playback: {e}") finally: if pwm is not None: pwm.duty_u16(0); time.sleep_ms(50); pwm.deinit() Pin(AUDIO_PIN_NUM, Pin.OUT).value(0) time.sleep_ms(50) audio_shutdown_pin.value(0) print(f"--- Playback Finished ---") def calculate_altitude(p, p0): return 44330 * (1.0 - (p / p0)**(1/5.255)) def connect_mqtt(): print(f"Connecting to MQTT Broker: {secrets.MQTT_BROKER}...") client = MQTTClient(secrets.MQTT_CLIENT_ID, secrets.MQTT_BROKER, user=secrets.MQTT_USER, password=secrets.MQTT_PASS, keepalive=60) client.connect(); print("--- MQTT Broker Connected ---") return client # MQTT Connection Loop mqtt_client = None while mqtt_client is None: try: mqtt_client = connect_mqtt() except Exception as e: print(f"!!! MQTT Connect Failed. Retrying... Error: {e}") set_color(255, 0, 0); play_audio("error.wav"); time.sleep(4); set_color(0, 0, 0) # Main Application Logic print("--- Starting Main Loop ---") topics = { "temp": "scentinel/sensor/temperature", "hum": "scentinel/sensor/humidity", "pres": "scentinel/sensor/pressure", "aq": "scentinel/sensor/air_quality", "alt": "scentinel/sensor/altitude", "stat": "scentinel/status" } last_publish_time = time.time() - PUBLISH_INTERVAL_S button_pressed_time = 0; press_count = 0; last_button_state = button.value() play_audio("startup.wav"); set_color(0, 0, 20); mqtt_client.publish(topics["stat"], "Idle") while True: try: if button.value() == 0 and last_button_state == 1: time.sleep_ms(DEBOUNCE_MS) if button.value() == 0: press_count += 1; button_pressed_time = time.ticks_ms() last_button_state = button.value() if time.ticks_diff(time.ticks_ms(), button_pressed_time) > DOUBLE_PRESS_MS and press_count > 0: if press_count == 1: print("Event: Single press -> MANUAL_SPRAY") mqtt_client.publish(topics["stat"], "Manual Spray"); set_color(0, 255, 0) spray_cycle(); play_audio("good.wav") elif press_count >= 2: print("Event: Double press -> SCANNING") mqtt_client.publish(topics["stat"], "Scanning"); set_color(0, 255, 255); play_audio("scanning.wav") aq_score = (bme.gas / secrets.GAS_BASELINE) * 100 aq_score = min(100, aq_score) print(f"Scan Result: AQ = {aq_score:.2f}%") if aq_score < AIR_QUALITY_THRESHOLD: print("Result: Air quality poor -> ACTION_REQUIRED") mqtt_client.publish(topics["stat"], "Action Required"); set_color(255, 0, 0); play_audio("bad.wav") spray_cycle(); set_color(0, 255, 0); play_audio("good.wav"); time.sleep(2) else: print("Result: Air quality good -> ALL_CLEAR") mqtt_client.publish(topics["stat"], "All Clear"); set_color(0, 255, 0); play_audio("good.wav"); time.sleep(2) press_count = 0; set_color(0, 0, 20); mqtt_client.publish(topics["stat"], "Idle") if time.time() - last_publish_time >= PUBLISH_INTERVAL_S: temp, hum, pres, gas = bme.temperature, bme.humidity, bme.pressure, bme.gas alt = calculate_altitude(pres, secrets.SEA_LEVEL_PRESSURE) aq_score = min(100, (gas / secrets.GAS_BASELINE) * 100) print(f"Publishing data... (AQ: {aq_score:.2f}%)") mqtt_client.publish(topics["temp"], f"{temp:.2f}"); mqtt_client.publish(topics["hum"], f"{hum:.2f}") mqtt_client.publish(topics["pres"], f"{pres:.2f}"); mqtt_client.publish(topics["aq"], f"{aq_score:.2f}") mqtt_client.publish(topics["alt"], f"{alt:.2f}") last_publish_time = time.time() mqtt_client.check_msg(); time.sleep_ms(20) except (KeyboardInterrupt): break except Exception as e: print(f"!!! Main loop error: {e}. Reconnecting..."); time.sleep(5) try: mqtt_client = connect_mqtt() except Exception as e_conn: print(f"!!! Reconnect failed: {e_conn}"); play_audio("error.wav"); time.sleep(10) print("Exiting. Cleaning up."); set_color(0,0,0); motor_stop(); sleep_pin.low(); audio_shutdown_pin.value(0) ```