Contents

IoT Air Purifier

Last november I bought an air purifier on amazon. The selling point was the hepa filter and the low price. This thing is “ok”, per se: it filters air, it has got a button and some leds.

Then comes the noise… there’s no way you can have this thing screaming in the bedroom all the night. And the published 32db are plain bullshit.

Before

/2021/iot-air-purifier/before_1_hu_3d8e737dbe6bd9c2.webp /2021/iot-air-purifier/before_2_hu_78420c80579d9fa1.webp /2021/iot-air-purifier/before_3_hu_f17b5a46dde5ad78.webp

As you can guess, there’s a standard fan inside.

Inside

/2021/iot-air-purifier/inside_1_hu_744a4e7ceb5c870c.webp

It’s a standard 80mm 5V DC fan, without PWM, without tachymetric return, DC only. There’s also a connector board, and something hidden below.

/2021/iot-air-purifier/inside_2_hu_f55e3fd77c407615.webp

Below the fan there’s a PCB, which manages the four LEDs, the button and controls the FAN.

My whishlist

These are the modifications planned:

  • swap the noisy fan for a much quieter Noctua
  • install a D1-mini to control the fan, the button and the LEDs
  • if possible, no external modification

PCB modding

/2021/iot-air-purifier/inside_3_hu_aefa77ffd2b184e2.webp

Since I don’t want to resolder the button and invent something for the LEDs, I tried to use the existing PCB.

I used a multimeter to map the three pins on the top, which are (from left to right): FAN_OUT, GND, 5VDC.

I then mapped the pins on the IC on the bottom, and then I removed it.

/2021/iot-air-purifier/chip_removed_hu_e2b3d99bbe04b3fb.webp

signalPINPINsignal
FAN OUT54Button
LED 263LED 1
LED 372LED 4
GND81Vcc

/2021/iot-air-purifier/pins_soldered_hu_86b196ef00ed3dee.webp

I soldered some wires for the 4 LEDs and the button. Power and ground are taken from the top 3 pins. This soldering is nothing I’m proud of, but believe me, the hot glue I poured onto the PCB for “isolation” is something I don’t want to show.

The new fan

/2021/iot-air-purifier/noctua_hu_d37f83fa0051c875.webp

This fan (Noctua NF-A8 PWM) is amazing. At “normal” speed, it is almost silent. And it can be controlled by PWM. I needed a PWM fan because I can power it using the 5V from the power supply and control its speed via a signal from the D1-mini. The alternative was an external amplifier to drive the fan in DC. I like the PWM solution more.

So I chopped off the connector from the Noctua and connected everything to a D1-mini.

The controller

/2021/iot-air-purifier/testing_hu_1de510cd6a237709.webp

We’ll see the software part later on.

/2021/iot-air-purifier/wire_spiral_hu_69060633dd43cb94.webp

/2021/iot-air-purifier/wire_spiral_2_hu_efb4221c76a9daa7.webp

I soldered the wires on the D1-mini and fitted the D1-mini in the place where the connector board was. Then I encountered a small problem: the D1-mini is a bit bigger than the previous board and 2 plastic bezels were blocking the cover so I couldn’t close it.

Say goodbye to these plastic bezels.

/2021/iot-air-purifier/bezels_hu_7e218a1f1c691ea8.webp

The software

The best way to DIY something like this is ESPHome. I use it in my Home Assistant, so it’s easier to let them talk.

This is the source:

substitutions:
  display_name: my_purifier
  use_address: !secret ip_my_purifier
  static_ip: !secret ip_my_purifier

esphome:
  platform: ESP8266
  name: ${display_name}
  board: d1_mini
  on_boot:
    priority: -10
    then:
      - delay: 2s
      - fan.turn_on:
          id: myfan
          speed: LOW

wifi:
  use_address: ${use_address}
  ssid: !secret iot_wifi_ssid
  password: !secret iot_wifi_password
  fast_connect: true
  power_save_mode: none
  reboot_timeout: 10min
  manual_ip:
    static_ip: ${static_ip}
    gateway: !secret iot_network_gateway
    subnet: !secret iot_network_subnet
    dns2: !secret iot_network_dns2
    dns1: !secret iot_network_dns1

web_server:
  port: 80
    
logger:
    
api:
  reboot_timeout: 0s
    
ota:
  password: !secret ota_password
      
sensor: 
  - platform: wifi_signal
    name: ${display_name} WiFi RSSI
    update_interval: 60s

switch:
  - platform: restart
    name: ${display_name} Restart
    id: restart_switch

time:
  - platform: sntp
    id: mytime
    servers:
      - !secret iot_ntp_server
    on_time:
      - seconds: 0
        minutes: 0
        hours: 4
        days_of_week: MON-SUN
        then:
          - switch.toggle: restart_switch

mqtt:
  broker: !secret mqtt_broker
  username: !secret mqtt_username
  password: !secret mqtt_password
  discovery: True

#  PIN ASSIGNMENT
# 
# D0 fan tachy
# D1 led1
# D2 led2
# D3 led3
# D4 status led
# D5 led4
# D6 fan pwm
# D7 button
# D8 - spare

status_led:
  pin:
    number: D4
    inverted: True

fan:
  - platform: speed
    output: pwm_fan_output
    name: ${display_name} fan
    id: myfan
    speed:
      low: 0.25
      medium: 0.5
      high: 0.9

# Noctua NF-A8 5V PWM, tech specs:
#   https://noctua.at/en/nf-a8-5v-pwm/specification
# at 100% the fan runs at 2200 RPM, +/- 10%
# the minimum speed is 450 RPM, +/- 20%, we use a 540rpm, which is around 25%

# FAN wire colors:
# - blue: PWM command
# - green: fan tachy return
# - yellow: 5V
# - black: GND

light:
  - platform: monochromatic
    output: pwm_led1_output
    name: ${display_name} LED1
    id: led_light_1
    effects:
       - lambda:
           name: Breathing SLOW
           update_interval: 10s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_1).turn_on();
            call.set_transition_length(9300);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing MID
           update_interval: 5s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_1).turn_on();
            call.set_transition_length(4500);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing FAST
           update_interval: 2s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_1).turn_on();
            call.set_transition_length(1800);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }             

  - platform: monochromatic
    output: pwm_led2_output
    name: ${display_name} LED2
    id: led_light_2
    effects:
       - lambda:
           name: Breathing SLOW
           update_interval: 10s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_2).turn_on();
            call.set_transition_length(9300);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing MID
           update_interval: 5s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_2).turn_on();
            call.set_transition_length(4500);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing FAST
           update_interval: 2s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_2).turn_on();
            call.set_transition_length(1800);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }             

  - platform: monochromatic
    output: pwm_led3_output
    name: ${display_name} LED3
    id: led_light_3
    effects:
       - lambda:
           name: Breathing SLOW
           update_interval: 10s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_3).turn_on();
            call.set_transition_length(9300);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing MID
           update_interval: 5s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_3).turn_on();
            call.set_transition_length(4500);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing FAST
           update_interval: 2s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_3).turn_on();
            call.set_transition_length(1800);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }             

  - platform: monochromatic
    output: pwm_led4_output
    name: ${display_name} LED4
    id: led_light_4
    effects:
       - lambda:
           name: Breathing SLOW
           update_interval: 10s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_4).turn_on();
            call.set_transition_length(9300);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing MID
           update_interval: 5s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_4).turn_on();
            call.set_transition_length(4500);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }
       - lambda:
           name: Breathing FAST
           update_interval: 2s
           lambda: |-
            static int state = 0;
            static int color = 1;
            auto call = id(led_light_4).turn_on();
            call.set_transition_length(1800);
            if (state == 0) {
             call.set_brightness(1.0);
            } else if (state == 1) {
             call.set_brightness(0.01);
            }
             call.perform();
             state ++;
             if (state == 2){
             state = 0;
             }             
output:
  - platform: esp8266_pwm
    pin: D6
    frequency: 2200 Hz
    id: pwm_fan_output

  - platform: esp8266_pwm
    pin: D1
    inverted: true
    frequency: 1000 Hz
    id: pwm_led1_output
  - platform: esp8266_pwm
    pin: D2
    inverted: true
    frequency: 1000 Hz
    id: pwm_led2_output
  - platform: esp8266_pwm
    pin: D3
    inverted: true
    frequency: 1000 Hz
    id: pwm_led3_output
  - platform: esp8266_pwm
    pin: D5
    inverted: true
    frequency: 1000 Hz
    id: pwm_led4_output

binary_sensor:
  - platform: gpio
    pin:
      number: D7
      mode: INPUT_PULLUP
    name: ${display_name} button
    filters:
      - invert:
      - delayed_on: 10ms

  - platform: status
    name: ${display_name} Status

I use MQTT in my setup, but it’s not the common choice. This would be using the ESPhome APIs which integrates seamlessly into Home Assistant.

If you want to use the above yaml, you have to provide your settings in place of the “secrets”.

I usually put in all my ESPhome devices:

  • a sensor for the wifi signal
  • a status binary sensor to check if the device is connected from Home Assistant
  • a weekly reboot
  • a remote restart command
  • a “status led” function, which I use on the D1-mini LED

On device start, the fan is switched on at low speed.

The button switches on and off the fan, but doesn’t control the speed. I didn’t implement this because I’ll probably never use the button anyway.

I still don’t know what I want the 4 LEDs to do. But I added a PWM control for all of them, combined with some “breathing” effect, in 3 different speeds. I can use these LEDs as light components in Home Assistant, so I can precisely dim the LEDs at specified brightness, or I can use these “breathing” effects.

The only interesting automation I did in Home Assistant is the speed change of the fan, according to the status of my room light: when I enter the room and I switch on the light, the speed is reduced to LOW; when I exit and switch off the light, the speed is changed to MID.

I also added a telegram warning if the device is disconnected for more than 5 minutes.

I connected but didn’t use the fan return signal. Maybe in the future I’ll add an alarm if the fan is stuck.

Costs

Somebody asked for the cost of this project; here’s the part list:

So the grand total is: € 40,89

Coming up next

I bought an air quality sensor.