Ventilating your home keeps the air fresh, but too much ventilation wastes energy.
This project controls a bathroom vent fan using a Z-wave smart switch, providing "just enough" ventilation to maintain a target CO₂ level. The code runs on a Raspberry Pi with no network access and a read-only filesystem. The hardware is assembled from off-the-shelf parts.
- UltraPro Z-Wave Plus toggle switch (also tested with GE enbrighten Z-Wave)
- Raspberry Pi 3 B+ or AML-S905X-CC "Le Potato"
- Adafruit SCD-30 CO2 sensor + STEMMA QT with female sockets
- Z-Wave.Me RaZberry2 (also tested with HUSBZ-1 USB Z-wave controller)
- CanaKit Premium Black Case (some internal cutting required)
- CanaKit MicroUSB power supply
-
Burn Raspberry Pi OS Lite to an SD card
-
Configure WiFi and/or SSH if desired
-
Install random stuff:
sudo apt install -y git screen python3-pip pip3 install adafruit-circuitpython-scd30 adafruit-extended-bus git clone https://github.com/pmarks-net/exhale.git
-
Apparently python-openzwave 0.4.19 doesn't install on RPiOS 11, so perhaps it was a bad idea to depend on this library, but for now it's still buildable:
sudo apt install -y cython3 libopenzwave1.6-dev git clone https://github.com/OpenZWave/python-openzwave.git cd python-openzwave sed -i 's/Cython==0.28.6/Cython>=0.29/' pyozw_setup.py ./setup-lib.py install --user --flavor=shared
-
Configure GPIO pins (Raspberry Pi):
# /dev/ttyS0 (zwave controller) on GPIO 14-15: sudo raspi-config nonint do_serial 2 # /dev/i2c-302 (scd30) on GPIO 9-10: sudo raspi-config nonint do_i2c 0 echo 'dtoverlay=i2c-gpio,bus=302,i2c_gpio_scl=9,i2c_gpio_sda=10' | sudo tee -a /boot/config.txt sudo reboot -
Configure GPIO pins (Le Potato):
cd git clone https://github.com/libre-computer-project/libretech-wiring-tool cp -v exhale/lepotato/i2c-exhale.dts libretech-wiring-tool/libre-computer/aml-s905x-cc/dt/ cd libretech-wiring-tool make # "enable" is temporary, so also do this from rc.local: sudo ./ldto enable i2c-exhale uarta -
Play with
calibrate,reset, andrunin that order:$ ./exhale.py --help === subcommand 'calibrate' === usage: exhale.py calibrate [-h] [--scd30_i2c N] [--scd30_ppm PPM] Calibrate the SCD30 CO₂ sensor in outdoor air. LED will blink slow for 2 minutes, calibrate, then blink quickly. Without --scd30_ppm, this just tests the sensor. optional arguments: -h, --help show this help message and exit --scd30_i2c N Read from SCD30 at /dev/i2c-N (default=auto) --scd30_ppm PPM Outdoor CO₂ ppm (default=dry_run) === subcommand 'reset' === usage: exhale.py reset [-h] [--zdevice /dev/ttyX] --switches N Reinitialize the ZWave network. Before running this command, all switches must be in the 'factory reset' state. To factory reset an UltraPro Z-Wave toggle switch, quickly press 'up up up down down down'. Later when prompted, press 'up' to add each switch to the ZWave network. optional arguments: -h, --help show this help message and exit --zdevice /dev/ttyX ZWave serial device (default=auto) --switches N Number of switches to add === subcommand 'run' === usage: exhale.py run [-h] [--zdevice /dev/ttyX] [--scd30_i2c N] [--co2_limit 900] [--co2_diff 50] [--manual 3600] Run the daemon to monitor CO₂ levels and control exhaust fans. optional arguments: -h, --help show this help message and exit --zdevice /dev/ttyX ZWave serial device (default=auto) --scd30_i2c N Read from SCD30 at /dev/i2c-N (default=auto) --co2_limit 900 Enable fan when CO₂ level exceeds this ppm value --co2_diff 50 Disable fan when CO₂ level falls below (limit-diff) --manual 3600 When a switch is toggled manually, disable automatic control for this many seconds -
Add stuff to
/etc/rc.local(Raspberry Pi):# Set up the red LED chmod a+w /sys/class/leds/led1/brightness ln -s /sys/class/leds/led1/brightness /tmp/exhale.led # Use `screen -r` to see logs and debug: su pi -c "/home/pi/exhale/daemon.sh"
-
Add stuff to
/etc/rc.local(Le Potato):# Set up GPIOs /home/pi/libretech-wiring-tool/ldto enable i2c-exhale uarta # Set up the green LED (blue off, red blocked with electrical tape) echo 0 > /sys/class/leds/librecomputer:blue/brightness chmod a+w /sys/class/leds/librecomputer:system-status/brightness ln -s /sys/class/leds/librecomputer:system-status/brightness /tmp/exhale.led # Use `screen -r` to see logs and debug: su pi -c "/home/pi/exhale/daemon.sh"
-
Enable overlay file system, for read-only SD card: https://learn.adafruit.com/read-only-raspberry-pi
XXX that seems broken on Le Potato; here's a horrifying workaround.# Le Potato read-only filesystem: git clone https://github.com/adafruit/Raspberry-Pi-Installer-Scripts.git cd Raspberry-Pi-Installer-Scripts sed '1,10s,^exit 0,#exit 0,' -i read-only-fs.sh sed 's,/boot,/boot/efi,g' -i read-only-fs.sh sed 's,ext4,btrfs,g' -i read-only-fs.sh sudo ./read-only-fs.sh # Answer ynnny
- Attach the RaZberry and SCD30 as shown; insulate behind the SCD30 with electrical tape:
ZZZZZ............... ZZZZZ...RBY.K....... Z = RaZberry R = red (3.3V) B = blue (I2C data) Y = yellow (I2C clock) K = black (ground)
- Everything fits in the case when positioned like this:

- The only building modification is a Z-wave smart switch controlling the bathroom vent fan:

- Hot glue the RPi case to a power supply for wall mounting, somewhere within range of the z-wave switch:

- To see the red LED (faintly), drill a hole in the lid and fill with hot glue. Here it is blinking 8 times for >=800 ppm. Every fifth blink is slower:
