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_hu9311639217458083661.webp /2021/iot-air-purifier/before_2_hu1329643997876072906.webp /2021/iot-air-purifier/before_3_hu5044773907818487465.webp

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

Inside

/2021/iot-air-purifier/inside_1_hu11330382368701695980.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_hu17971240157687319349.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_hu6399320823026293750.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_hu10865989627449302412.webp

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

/2021/iot-air-purifier/pins_soldered_hu4760219024719813979.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_hu11604370691838026801.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_hu16305463329644258472.webp

We’ll see the software part later on.

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

/2021/iot-air-purifier/wire_spiral_2_hu8109790404261653740.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_hu5136313510326915755.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:

  1substitutions:
  2  display_name: my_purifier
  3  use_address: !secret ip_my_purifier
  4  static_ip: !secret ip_my_purifier
  5
  6esphome:
  7  platform: ESP8266
  8  name: ${display_name}
  9  board: d1_mini
 10  on_boot:
 11    priority: -10
 12    then:
 13      - delay: 2s
 14      - fan.turn_on:
 15          id: myfan
 16          speed: LOW
 17
 18wifi:
 19  use_address: ${use_address}
 20  ssid: !secret iot_wifi_ssid
 21  password: !secret iot_wifi_password
 22  fast_connect: true
 23  power_save_mode: none
 24  reboot_timeout: 10min
 25  manual_ip:
 26    static_ip: ${static_ip}
 27    gateway: !secret iot_network_gateway
 28    subnet: !secret iot_network_subnet
 29    dns2: !secret iot_network_dns2
 30    dns1: !secret iot_network_dns1
 31
 32web_server:
 33  port: 80
 34    
 35logger:
 36    
 37api:
 38  reboot_timeout: 0s
 39    
 40ota:
 41  password: !secret ota_password
 42      
 43sensor: 
 44  - platform: wifi_signal
 45    name: ${display_name} WiFi RSSI
 46    update_interval: 60s
 47
 48switch:
 49  - platform: restart
 50    name: ${display_name} Restart
 51    id: restart_switch
 52
 53time:
 54  - platform: sntp
 55    id: mytime
 56    servers:
 57      - !secret iot_ntp_server
 58    on_time:
 59      - seconds: 0
 60        minutes: 0
 61        hours: 4
 62        days_of_week: MON-SUN
 63        then:
 64          - switch.toggle: restart_switch
 65
 66mqtt:
 67  broker: !secret mqtt_broker
 68  username: !secret mqtt_username
 69  password: !secret mqtt_password
 70  discovery: True
 71
 72#  PIN ASSIGNMENT
 73# 
 74# D0 fan tachy
 75# D1 led1
 76# D2 led2
 77# D3 led3
 78# D4 status led
 79# D5 led4
 80# D6 fan pwm
 81# D7 button
 82# D8 - spare
 83
 84status_led:
 85  pin:
 86    number: D4
 87    inverted: True
 88
 89fan:
 90  - platform: speed
 91    output: pwm_fan_output
 92    name: ${display_name} fan
 93    id: myfan
 94    speed:
 95      low: 0.25
 96      medium: 0.5
 97      high: 0.9
 98
 99# Noctua NF-A8 5V PWM, tech specs:
100#   https://noctua.at/en/nf-a8-5v-pwm/specification
101# at 100% the fan runs at 2200 RPM, +/- 10%
102# the minimum speed is 450 RPM, +/- 20%, we use a 540rpm, which is around 25%
103
104# FAN wire colors:
105# - blue: PWM command
106# - green: fan tachy return
107# - yellow: 5V
108# - black: GND
109
110light:
111  - platform: monochromatic
112    output: pwm_led1_output
113    name: ${display_name} LED1
114    id: led_light_1
115    effects:
116       - lambda:
117           name: Breathing SLOW
118           update_interval: 10s
119           lambda: |-
120            static int state = 0;
121            static int color = 1;
122            auto call = id(led_light_1).turn_on();
123            call.set_transition_length(9300);
124            if (state == 0) {
125             call.set_brightness(1.0);
126            } else if (state == 1) {
127             call.set_brightness(0.01);
128            }
129             call.perform();
130             state ++;
131             if (state == 2){
132             state = 0;
133             }            
134       - lambda:
135           name: Breathing MID
136           update_interval: 5s
137           lambda: |-
138            static int state = 0;
139            static int color = 1;
140            auto call = id(led_light_1).turn_on();
141            call.set_transition_length(4500);
142            if (state == 0) {
143             call.set_brightness(1.0);
144            } else if (state == 1) {
145             call.set_brightness(0.01);
146            }
147             call.perform();
148             state ++;
149             if (state == 2){
150             state = 0;
151             }            
152       - lambda:
153           name: Breathing FAST
154           update_interval: 2s
155           lambda: |-
156            static int state = 0;
157            static int color = 1;
158            auto call = id(led_light_1).turn_on();
159            call.set_transition_length(1800);
160            if (state == 0) {
161             call.set_brightness(1.0);
162            } else if (state == 1) {
163             call.set_brightness(0.01);
164            }
165             call.perform();
166             state ++;
167             if (state == 2){
168             state = 0;
169             }                         
170
171  - platform: monochromatic
172    output: pwm_led2_output
173    name: ${display_name} LED2
174    id: led_light_2
175    effects:
176       - lambda:
177           name: Breathing SLOW
178           update_interval: 10s
179           lambda: |-
180            static int state = 0;
181            static int color = 1;
182            auto call = id(led_light_2).turn_on();
183            call.set_transition_length(9300);
184            if (state == 0) {
185             call.set_brightness(1.0);
186            } else if (state == 1) {
187             call.set_brightness(0.01);
188            }
189             call.perform();
190             state ++;
191             if (state == 2){
192             state = 0;
193             }            
194       - lambda:
195           name: Breathing MID
196           update_interval: 5s
197           lambda: |-
198            static int state = 0;
199            static int color = 1;
200            auto call = id(led_light_2).turn_on();
201            call.set_transition_length(4500);
202            if (state == 0) {
203             call.set_brightness(1.0);
204            } else if (state == 1) {
205             call.set_brightness(0.01);
206            }
207             call.perform();
208             state ++;
209             if (state == 2){
210             state = 0;
211             }            
212       - lambda:
213           name: Breathing FAST
214           update_interval: 2s
215           lambda: |-
216            static int state = 0;
217            static int color = 1;
218            auto call = id(led_light_2).turn_on();
219            call.set_transition_length(1800);
220            if (state == 0) {
221             call.set_brightness(1.0);
222            } else if (state == 1) {
223             call.set_brightness(0.01);
224            }
225             call.perform();
226             state ++;
227             if (state == 2){
228             state = 0;
229             }                         
230
231  - platform: monochromatic
232    output: pwm_led3_output
233    name: ${display_name} LED3
234    id: led_light_3
235    effects:
236       - lambda:
237           name: Breathing SLOW
238           update_interval: 10s
239           lambda: |-
240            static int state = 0;
241            static int color = 1;
242            auto call = id(led_light_3).turn_on();
243            call.set_transition_length(9300);
244            if (state == 0) {
245             call.set_brightness(1.0);
246            } else if (state == 1) {
247             call.set_brightness(0.01);
248            }
249             call.perform();
250             state ++;
251             if (state == 2){
252             state = 0;
253             }            
254       - lambda:
255           name: Breathing MID
256           update_interval: 5s
257           lambda: |-
258            static int state = 0;
259            static int color = 1;
260            auto call = id(led_light_3).turn_on();
261            call.set_transition_length(4500);
262            if (state == 0) {
263             call.set_brightness(1.0);
264            } else if (state == 1) {
265             call.set_brightness(0.01);
266            }
267             call.perform();
268             state ++;
269             if (state == 2){
270             state = 0;
271             }            
272       - lambda:
273           name: Breathing FAST
274           update_interval: 2s
275           lambda: |-
276            static int state = 0;
277            static int color = 1;
278            auto call = id(led_light_3).turn_on();
279            call.set_transition_length(1800);
280            if (state == 0) {
281             call.set_brightness(1.0);
282            } else if (state == 1) {
283             call.set_brightness(0.01);
284            }
285             call.perform();
286             state ++;
287             if (state == 2){
288             state = 0;
289             }                         
290
291  - platform: monochromatic
292    output: pwm_led4_output
293    name: ${display_name} LED4
294    id: led_light_4
295    effects:
296       - lambda:
297           name: Breathing SLOW
298           update_interval: 10s
299           lambda: |-
300            static int state = 0;
301            static int color = 1;
302            auto call = id(led_light_4).turn_on();
303            call.set_transition_length(9300);
304            if (state == 0) {
305             call.set_brightness(1.0);
306            } else if (state == 1) {
307             call.set_brightness(0.01);
308            }
309             call.perform();
310             state ++;
311             if (state == 2){
312             state = 0;
313             }            
314       - lambda:
315           name: Breathing MID
316           update_interval: 5s
317           lambda: |-
318            static int state = 0;
319            static int color = 1;
320            auto call = id(led_light_4).turn_on();
321            call.set_transition_length(4500);
322            if (state == 0) {
323             call.set_brightness(1.0);
324            } else if (state == 1) {
325             call.set_brightness(0.01);
326            }
327             call.perform();
328             state ++;
329             if (state == 2){
330             state = 0;
331             }            
332       - lambda:
333           name: Breathing FAST
334           update_interval: 2s
335           lambda: |-
336            static int state = 0;
337            static int color = 1;
338            auto call = id(led_light_4).turn_on();
339            call.set_transition_length(1800);
340            if (state == 0) {
341             call.set_brightness(1.0);
342            } else if (state == 1) {
343             call.set_brightness(0.01);
344            }
345             call.perform();
346             state ++;
347             if (state == 2){
348             state = 0;
349             }                         
350output:
351  - platform: esp8266_pwm
352    pin: D6
353    frequency: 2200 Hz
354    id: pwm_fan_output
355
356  - platform: esp8266_pwm
357    pin: D1
358    inverted: true
359    frequency: 1000 Hz
360    id: pwm_led1_output
361  - platform: esp8266_pwm
362    pin: D2
363    inverted: true
364    frequency: 1000 Hz
365    id: pwm_led2_output
366  - platform: esp8266_pwm
367    pin: D3
368    inverted: true
369    frequency: 1000 Hz
370    id: pwm_led3_output
371  - platform: esp8266_pwm
372    pin: D5
373    inverted: true
374    frequency: 1000 Hz
375    id: pwm_led4_output
376
377binary_sensor:
378  - platform: gpio
379    pin:
380      number: D7
381      mode: INPUT_PULLUP
382    name: ${display_name} button
383    filters:
384      - invert:
385      - delayed_on: 10ms
386
387  - platform: status
388    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.