This article is about a small sensor node with a decorative case. It is based on the Raspberry Pi Zero W board with a custom sensor shield.
I publish all hardware files for a simple sensor version, so you should be able to build this kind of sensor node and use it to monitor anything you like. You can also extend/modify the design easily with additional sensors. Nevertheless, the case lid design is based around the Plantower PMSA003 particle sensor. It has all the required air vents for this use.
The term “node” is used because the idea of this sensor is to use a large number of these nodes in a network to monitor location and time-based sensor data.
Update 2022-01-07: Added additional images to show the shield assembly in more detail. Fixed the spelling of various sentences.
The Required Hardware

The simplified design is assembled using the following components:
- Raspberry Pi Zero W with soldered headers.
- Minimalistic Custom Shield (see description below)
- Short 40 Pin Header for Raspberry Pi
- Plantower PMSA003 Particle Sensor
- Optional: MICS-VZ-89TE VOC Sensor
- Four short M2.5 screws and nuts to fasten the Raspberry Pi in the case.
- Optional four M2.5 10mm long spacer to also fasten the shield.
- One or two short M2 screws to fasten the Plantower sensor to the shield.
- USB Cable and power supply to power the node.
The Raspberry Pi Zero W
I use the Raspberry Pi Zero W because of its compact size and computing power. Each node can prepare the sensor data, which takes a lot of load from the central processing unit. Also, using a platform like Raspberry Pi easily allows one to run a whole web server on each node – so one can query the sensor data of each node independently. This makes testing a breeze.
The shield works with any variant of Raspberry Pi so that you can use the larger models for testing. Nevertheless, the case is designed to house a Raspberry Pi Zero W and will not work with any other board.
The Shield
Order the board for the shield from PcbWay.com or OSHPark at a very low price. The shield (“Flower”) I publish here is a very minimalistic version – meant as a good start for your project.
Download the files here:
As you can see in the photos, there is enough space for many sensors. The particle sensor will create an airflow in the case so that you can arrange a large number of sensors on a large shield.
Shield Assembly Images
The following images from the shield are based on a prototype. There are small but not functional differences to the provided Gerber files.






Short 40 Pin Header
To attach the shield to the Raspberry Pi, you need a 40 Pin header. You can use the one from Adafruit or buy any header you like. Just make sure you buy a header with a low profile. The maximum height of the header should be 6mm, and 5mm is the optimal height.
This is important for two reasons. First, the distance of the shield to the Raspberry Pi should be 10mm to match the vents in the case. Second, the short header leaves a gap between the shield and the processor board, and this creates a better airflow from the CPU to the air vents on the top.
Plantower PMSA003 Particle Sensor
The Plantower PMSA003 is a tiny laser particle sensor which measures the number of PM1, PM2.5 and PM10 particles in the air. It is no scientifically accurate device, but the sensor comes calibrated, is robust and performs well.
You can get this sensor at a very low price from many Asian suppliers. Make sure you buy one that includes a 10-pin header you can solder to the shield. Otherwise, you need to buy this header separately. One compatible header is the Amphenol 20021311-00010T4LF.
The Optional MICS-VZ-89TE VOC Sensor
For example, I added pads for the Amphenol MICS-VZ-89TE VOC sensor. It is not a cheap sensor, but it can be accessed using I2C and is calibrated. It is also fairly easy to solder.
A little bit cheaper one is the Sensirion SGP30, but it has a 1.8V logic – which requires a level shifter, and it is really hard to hand solder. Alternatively, buy the breakout board from Adafruit and solder it on the shield. In any case, you have to edit the board files.
If you only need a particle sensor, use the unmodified board files and ignore the Sensirion sensor. It does not affect the functionality of the particle sensor.
Screws and Cables
You need four M2.5 screws and four matching nuts to fasten the Raspberry Pi to the case. This will fasten the Raspberry Pi to the case, and the header holds the shield. It is usually enough for most cases.
To make a more stable construction, use four 10mm long M2.5 female-to-female spacers and eight M2.5 screws. Now you can fasten the Raspberry Pi to the case with the spacer and fasten the shield to the spacer from the top. This will give you the best stability.
The Case

The case is made from two separate parts. A generic lower part and the lid.
While the lower part is generic, the upper part (lid) is shaped with vents for the sensors on the shield. This concept aims to add additional sensors for an installed node by just replacing the shield and lid.
The design files I provided with this article do not contain any fastening mechanism to hold the lid to the lower part. Things like this would need the exact knowledge of the used 3D printing method – therefore, they are omitted from these files. Also, the case has 0.1mm between the upper and lower part to make it useable with any printing method.
Just use two small points of hot glue to fasten the two case parts. You can easily separate them later with some force or a little bit of hot air. Or use the design file to add some hooks.
The Lower Part Explained

- A – Slot for access to the SD card.
- B – Sockets to fasten the Raspberry Pi to the case.
- C – Slot for the USB power cable.
- D – Two holes if you like to screw the sensor to a wall.
- E – Air vents.
Production of the Case
You can produce the case with any 3D printer. All case walls are 2mm thick, so you can print it even using cheap additive methods. For the best decoration results on top, you should choose layers of a maximum of 0.15mm.
Use the following files to print the case as shown:
STL Files for Lower and Upper Part
If you want to change the design, you can use the Fusion 360 file as a base. I had to remove external references from this file. Therefore it is not as parametric as it could be. But you can use it as a good reference if you want to create your lid version.
Raspberry Pi Zero W Setup

Core Setup
- First, set up an SD card with Raspbian Stretch Lite.
- Mount the SD card and modify some files to ensure it will connect to the WIFI and is accessible via SSH.
- Add the WIFI details to the
boot
directory: cd /???/boot
nano /???/boot/wpa_supplicant.conf
- Add the content with the correct credentials:
country=??
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
network={
ssid="MyWiFiNetwork"
psk="aVeryStrongPassword"
key_mgmt=WPA-PSK
}
- Enable the SSH server, if this is not already the case.
touch /???/boot/ssh
First Start
- Start the node using this SD card and connect.
ssh pi@<ip of your node>
Secure the Node
- Change the password of the user
pi
passwd
- Use a random >= 32-character password.
- Update all packages.
sudo apt update
sudo apt upgrade
sudo rpi-update
Disable the Console on the Serial Line
sudo raspi-config
- First, extend the file system.
- Disable the console on the serial line.
Make sure the Bluetooth module does not use the serial line:
sudo systemctl disable hciuart
sudo nano /boot/config.txt
Add the following lines:
# Disable BT to allow using the serial line.
dtoverlay=pi3-disable-bt
Add your SSH Public Key
Install your SSH public key for simple access:
mkdir .ssh
chmod 700 .ssh
nano .ssh/authorized_keys
Reboot!
sudo reboot now
Make Sure the Time is Updated
Set up the network to be sure the clock is automatically set using an NTP daemon. This should be the default. Check using:
timedatectl
sudo apt install ntp
sudo nano /etc/ntp.conf
Either use a local NTP server:
- Add/uncomment
Server <ip of NTP server>
- Comment out all pool settings.
Or keep the pool settings.
Add the line deny all
to prevent any access to the NTP daemon.
Install Required Packages
Next, install the required packages for the python scripts:
sudo apt install python3 python3-serial
Example Software

Reading from the Plantower Sensor
display_pm_values.py
#!/usr/bin/python3 """ Display live sensor values. """ import configparser import time import serial import pms_a03 from datetime import datetime from pathlib import Path # Methods # --------------------------------------------------------------------------- def read_value(sensor): """Read a sensor value and store it in the database.""" # Read the value from the sensor. reading = sensor.read() now = datetime.utcnow() print("{}: pm10: {:8d} pm25: {:8d} pm100: {:8d}".format(now.isoformat(), reading.pm10_cf1, reading.pm25_cf1, reading.pm100_cf1)) def main(): """The main method""" # Access the sensor over the serial line. sensor = pms_a03.Sensor("/dev/serial0") # Reading sensor data until terminated while True: read_value(sensor) time.sleep(2) if __name__ == '__main__': main() # EOF
This code uses the following module, which has to be placed in the same directory. This module is based on the code of the plantower module by Philip Basford. My rewritten version uses a different way to time the sensor reads to reduce the CPU load on the Raspberry Pi. It is still not optimal due to some issues in the serial
library.
pms_a03.py
""" Interface to read from the plantower PMS A003 sensor. """ import serial import time # Constants # --------------------------------------------------------------------------- DEFAULT_SERIAL_PORT = "/dev/serial0" DEFAULT_READ_TIMEOUT = 2 # Classes # --------------------------------------------------------------------------- class SensorReading(object): """One single reading.""" def __init__(self, line): self.pm10_cf1 = line[4] * 256 + line[5] self.pm25_cf1 = line[6] * 256 + line[7] self.pm100_cf1 = line[8] * 256 + line[9] self.pm10_std = line[10] * 256 + line[11] self.pm25_std = line[12] * 256 + line[13] self.pm100_std = line[14] * 256 + line[15] self.gr03um = line[16] * 256 + line[17] self.gr05um = line[18] * 256 + line[19] self.gr10um = line[20] * 256 + line[21] self.gr25um = line[22] * 256 + line[23] self.gr50um = line[24] * 256 + line[25] self.gr100um = line[26] * 256 + line[27] class SensorException(Exception): """Exception if no data can be read.""" pass class Sensor(object): """The interface class.""" def __init__(self, port=DEFAULT_SERIAL_PORT, read_timeout=DEFAULT_READ_TIMEOUT): self.port = port self.read_timeout = read_timeout try: self.serial = serial.Serial(port=self.port, baudrate=9600, timeout=1) except serial.SerialException as e: raise SensorException(str(e)) def _verify(self, recv): """Verify the checksum of the data.""" calc = 0 ord_arr = [] for c in bytearray(recv[:-2]): calc += c ord_arr.append(c) sent = (recv[-2] << 8) | recv[-1] if sent != calc: raise SensorException("Checksum invalid") def read(self): """Read a new value from the sensor.""" recv = b'' timeout_time = time.monotonic() + self.read_timeout self.serial.reset_input_buffer() while True: inp = self.serial.read(1) if inp == '': time.sleep(0.1) continue if inp == b'\x42': recv += inp inp = self.serial.read(1) if inp == b'\x4d': recv += inp recv += self.serial.read(30) break if time.monotonic() > timeout_time: raise SensorException("No message recieved") self._verify(recv) return SensorReading(recv)
Reading from the VOC Sensor
Reading from the VOC sensor is not very simple to do using Python. Here is a small C++ command to read the current values from the Sensor and print them as JSON data.
Call this small command from your python code to get the current values from the sensor.
read_mics_vz_89te.cpp
// // Read the current values from the MiCS VZ 89TE sensor. // --------------------------------------------------------------------------- // (c)2019 by Lucky Resistor. See LICENSE for details. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 2 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // #include <unistd.h> #include <fcntl.h> #include <sys/ioctl.h> #include <linux/i2c-dev.h> #include <chrono> #include <thread> #include <cstdint> #include <iostream> // This code is required, because this sensor has a little bit special // protocol to access the values. /// The I2C address of the chip. /// const uint8_t cChipAddress = 0x70; /// The device to use. /// const std::string cDevicePath = "/dev/i2c-1"; /// The delay to wait before the result is read from the sensor. /// const std::chrono::milliseconds cReadDelay(300); /// The command. /// enum class Command : uint8_t { SetPPMCO2 = 0b000001000, GetStatus = 0b000001100, GetRevision = 0b000001101, GetR0 = 0b000010000 }; /// The file pointer to the I2C device. /// int i2c_fd = 0; /// If the command shall display verbose messages. /// bool isVerbose = false; /// Open the I2C device. /// /// @return `true` on success. /// bool openBus() { if (isVerbose) { std::cout << "# Open the bus." << std::endl; } i2c_fd = open(cDevicePath.c_str(), O_RDWR); if (i2c_fd < 0) { std::cerr << "Failed to open the I2C bus device. Path: " << cDevicePath << std::endl; return false; } if (ioctl(i2c_fd, I2C_SLAVE, cChipAddress) < 0) { std::cerr << "Failed to configure the I2C bus device. Error: " << errno << std::endl; return false; } return true; } /// Close the I2C device. /// /// @return `true` on success. /// bool closeBus() { if (isVerbose) { std::cout << "# Close the bus." << std::endl; } close(i2c_fd); return true; } /// Write data to the I2C bus. /// /// @param data The data to write. /// @param size The size of the data to write. /// @return `true` on success. /// bool writeData(const uint8_t *data, int size) { if (write(i2c_fd, data, size) != size) { std::cerr << "Failed to write to the bus. Error: " << errno << std::endl; return false; } return true; } /// Read data from the I2C bus. /// /// @param data The buffer to read data into it. /// @param size The number of bytes to read. /// @return `true` on success. /// bool readData(uint8_t *data, int size) { if (read(i2c_fd, data, size) != size) { std::cerr << "Failed to read from the bus. Error: " << errno << std::endl; return false; } return true; } /// Calculate the checksum for a command. /// /// @param buffer The buffer to use for calculation. /// @param size The size of the buffer. /// @return The calculated checksum. /// uint8_t getCRC(const uint8_t *buffer, const uint8_t size) { uint16_t sum = 0; for (uint8_t i=0; i<size; i++) { sum += buffer[i]; } uint8_t crc = static_cast<uint8_t>(sum); crc += (sum / 0x0100); crc = (0xff-crc); return crc; } /// Write a command to the bus. /// /// @param cmd The command to send. /// @param data The data to attach to the command. /// @return `true` on succes. /// bool writeCommand(const Command cmd, const uint32_t data = 0) { uint8_t buffer[6]; buffer[0] = static_cast<uint8_t>(cmd); buffer[1] = static_cast<uint8_t>(data & 0x000000ffUL); buffer[2] = static_cast<uint8_t>((data & 0x0000ff00UL)>>8); buffer[3] = static_cast<uint8_t>((data & 0x00ff0000UL)>>16); buffer[4] = static_cast<uint8_t>((data & 0xff000000UL)>>24); buffer[5] = getCRC(buffer, 5); const auto result = writeData(buffer, 6); if (!result) { std::cerr << "Failed to send a command to the bus." << std::endl; } return result; } /// Read a result from the bus. /// /// @param data The data to read. /// @return `true` on success. /// bool readResult(uint8_t data[7]) { if (!readData(data, 7)) { return false; } const uint8_t crc = getCRC(data, 6); if (data[6] != crc) { std::cerr << "The checksum of the received result is not valid." << std::endl; return false; } return true; } /// Get the current values from the sensor. /// /// @param[out] voc The voc value. /// @param[out] co2 The co2 value. /// @return `true` on success. /// bool getValues(float &voc, float &co2) { if (isVerbose) { std::cout << "# Reading the current values." << std::endl; } // Repeat the read three times if necessary. for (uint8_t comTry = 0; comTry < 3; ++comTry) { if (writeCommand(Command::GetStatus)) { std::this_thread::sleep_for(cReadDelay); uint8_t data[7]; if (readResult(data)) { if (isVerbose) { const uint32_t rawValue = (static_cast<uint32_t>(data[2])<<16)|(static_cast<uint32_t>(data[3])<<8)|static_cast<uint32_t>(data[2]); std::cout << "# Raw data values: voc=" << static_cast<uint32_t>(data[0]) << " co2=" << static_cast<uint32_t>(data[1]) << " raw=" << rawValue << std::endl; } //voc = static_cast<float>(static_cast<int16_t>(data[0]-13)) * static_cast<float>(1000.0f/229.0f); //co2 = (static_cast<float>(static_cast<int16_t>(data[1]-13)) * static_cast<float>(1600.0f/229.0f)) + static_cast<float>(400.0f); // Formula supplied by the manufacturer: voc = ((data[0] - 13) * (1000 / 229)); co2 = ((data[1] - 13) * (1600 / 229) + 400); return true; } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } } std::cerr << "Failed to read the values from the sensor." << std::endl; return false; } /// Parse the command line parameters. /// bool parseCommandLine(int argc, char *argv[]) { for (int i = 0; i < argc; ++i) { const auto arg = std::string(argv[i]); if (arg == "-h" || arg == "--help") { std::cerr << "Usage: read_mics_vz_89te [-h][-v]" << std::endl; std::cerr << " -h|--help Display this help." << std::endl; std::cerr << " -v Show verbose messages." << std::endl; return false; } else if (arg == "-v") { isVerbose = true; } } return true; } int main(int argc, char *argv[]) { // Initialize and open the bus. if (isVerbose) { std::cout << "# Success." << std::endl; } if (!parseCommandLine(argc, argv)) { return 1; } if (!openBus()) { return 1; } float voc = 0.0f; float co2 = 0.0f; if (!getValues(voc, co2)) { return 1; } // Output the values as JSON std::cout << "{ \"voc\": " << voc << ", \"co2\": " << co2 << " }" << std::endl; // Close the bus. closeBus(); // Success if (isVerbose) { std::cout << "# Success." << std::endl; } return 0; }
Use the following CMake
file to build this code:
CMakeLists.txt
cmake_minimum_required (VERSION 3.7) project (read_mics_vz_89te) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++17") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/../../bin) add_executable(read_mics_vz_89te read_mics_vz_89te.cpp)
Image Gallery







If you have questions, miss some information or have any feedback, feel free to add a comment below.
Have fun!
1 thought on “Tiny Particle Sensor Node with Decorative Case”