I recently purchased a fume extractor but was completely underwhelmed by how poorly the fan was controlled. The speed varied between 0 and 30% and would then stay at max until 100%.
This was the original product:
The fan was a regular 24V fan without PWM control (even though the part number of the fan took me to a datasheet that showed specifications of an identical fan with power + pwm + tach. Anyway, even trying to control the fan with an ESP32 did not make a difference so… I put in a better fan that takes PWM!
I tore out the 80s looking PWM motor controller (meant for regular motors) and built my own using the outer casing of the original one.
(Sorry for the horrible pictures…)
Installed
View of bottom with particulate sensor (PMS5003)
Now my question… looking for suggestions…
Is there a better, less bulky sensor that you recommend? I know there are many and I read about a lot of them but do not want to start buying them all to figure it out if possible.
I am guessing that the ideal placement is in a hood at the entrance of the pipe (see amazon picture). I don’t have a 3D printer though… and this came without a hood.
I thought about putting it inside the box where the filter (before the filter of course) is but there is no room.
I am at the stage where I need to make a hole for a panel mount connector to connect the sensor… all I could find was an aviation style connector, also commonly found on ham radios. It is a bit big but would work.
Before I make the huge hole, I was hoping to get suggestions, ideas, on sensor, connector and placement of the sensor.
On the software side… of course it is controllable via HA…
This is my code:
substitutions:
devicename: fume-extractor
devicename_no_dashes: fume_extractor
friendly_devicename: "Fume Extractor"
device_description: "Fume Extractor"
update_interval_s: "1s"
update_interval_wifi: "60s"
low_speed: "20"
medium_speed: "60"
high_speed: "100"
manual_fan_speed_step: "5"
esphome:
name: ${devicename}
comment: ${device_description}
on_boot:
then:
- lambda: |-
id(device_on_off_ha).publish_state(true);
id(set_max_particulate_ha).execute();
esp32:
board: nodemcu-32s
framework:
type: arduino
#Investigate globals to see if they always get written on only if marked as restore.
#This setting preserves the flash by limiting writes
preferences:
flash_write_interval: 5min
# Enable logging
logger:
baud_rate: 0 #Disable logger on UART since it is being used for the sensor. Helps not overload.
# Enable Home Assistant API
api:
password: !secret api_pwd
ota:
password: !secret ota_pwd
wifi:
ssid: !secret iot_wifi_ssid
password: !secret iot_wifi_password
#Faster than DHCP. Also use if can't reach because of name change
manual_ip:
static_ip: 192.168.3.208
gateway: 192.168.3.1
subnet: 255.255.255.0
dns1: 192.168.1.25
dns2: 192.168.1.26
#fast_connect: true
#Manually override what address to use to connect to the ESP.
#Defaults to auto-generated value. Example, if you have changed your
#static IP and want to flash OTA to the previously configured IP address.
use_address: 192.168.3.208
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "${devicename}"
password: !secret iot_wifi_password
web_server:
port: 80
include_internal: true
captive_portal:
# Sync time with Home Assistant
time:
- platform: homeassistant
id: ha_time
text_sensor:
- platform: wifi_info
ip_address:
name: "${friendly_devicename}: IP"
id: device_ip
icon: "mdi:ip-outline"
update_interval: ${update_interval_wifi}
ssid:
name: "${friendly_devicename}: SSID"
icon: "mdi:wifi-settings"
update_interval: ${update_interval_wifi}
bssid:
name: "${friendly_devicename}: BSSID"
icon: "mdi:wifi-settings"
update_interval: ${update_interval_wifi}
mac_address:
name: "${friendly_devicename}: MAC"
icon: "mdi:network-outline"
scan_results:
name: "${friendly_devicename}: Wifi Scan"
icon: "mdi:wifi-refresh"
disabled_by_default: true
#https://esphome.io/guides/automations.html?highlight=restore_value#bonus-2-global-variables
globals: ##to set default reboot behavior
# Wifi variables
- id: wifi_connection
type: bool
restore_value: no
initial_value: "false"
# Menu variables
- id: menu_index
type: int
restore_value: no
initial_value: '0'
- id: device_on_off
type: bool
restore_value: no
initial_value: "true"
- id: display_on_off
type: bool
restore_value: no
initial_value: "true"
# Speed variables
- id: fan_speed
type: float
restore_value: no
initial_value: '0.0'
- id: fan_rpm
type: float
restore_value: no
initial_value: '0.0'
- id: pwm_man_speed
type: int
restore_value: no
initial_value: '0'
- id: man_speed
type: int
restore_value: no
initial_value: '0'
- id: speed_index
type: int
restore_value: no
initial_value: '0'
- id: speed_name
type: std::string
restore_value: no # Strings cannot be saved/restored
initial_value: '"Low"'
# Sensor variables
- id: particulate_1
type: int
restore_value: no
initial_value: '0'
- id: particulate_2_5
type: int
restore_value: no
initial_value: '0'
- id: particulate_10
type: int
restore_value: no
initial_value: '0'
- id: max_particulate
type: int
restore_value: yes
initial_value: '250'
font:
# gfonts://family[@weight]
- file: "gfonts://Roboto"
id: roboto
size: 12
- file: "gfonts://Roboto"
id: roboto_large
size: 32
- file: "fonts/materialdesignicons-webfont.ttf"
id: icon_font
size: 12
glyphs: [
"\U000F05A9", #wifi
"\U000F05AA", #no wifi
]
uart:
rx_pin: 16
tx_pin: 17
baud_rate: 9600
i2c:
- id: bus_a
sda: 19
scl: 18
scan: true
display:
- platform: ssd1306_i2c
id: device_display
model: 'SH1106 128x64'
address: 0x3C
rotation: 180
flip_x: false
flip_y: false
offset_y: 0
offset_x: 0
external_vcc: true
update_interval: 150ms
pages:
- id: standby_page
lambda: |-
it.rectangle(0, 0, 128, 64);
it.print(4, 1, id(roboto), "FUME EXTRACTOR");
it.printf(112, 2, id(icon_font), "%s", id(wifi_connection) ? "\U000F05A9" : "\U000F05AA");
it.line(0, 16, 128, 16);
it.printf(4, 18, id(roboto), "Mode: Standby");
it.printf(4, 32, id(roboto), "IP: %s", id(device_ip).state.c_str());
it.printf(4, 46, id(roboto), "Press knob to turn on.");
- id: auto_0_100_page
lambda: |-
it.rectangle(0, 0, 128, 64);
it.print(4, 1, id(roboto), "FUME EXTRACTOR");
it.printf(112, 2, id(icon_font), "%s", id(wifi_connection) ? "\U000F05A9" : "\U000F05AA");
it.line(0, 16, 128, 16);
it.printf(4, 18, id(roboto), "Mode: Auto 0-100%%");
it.printf(4, 32, id(roboto), "Fan: %d%%", int(id(fan_speed) * 100));
it.printf(4, 46, id(roboto), "PM 2.5: %d ug", id(particulate_2_5));
- id: auto_l_m_h_page
lambda: |-
it.rectangle(0, 0, 128, 64);
it.print(4, 1, id(roboto), "FUME EXTRACTOR");
it.printf(112, 2, id(icon_font), "%s", id(wifi_connection) ? "\U000F05A9" : "\U000F05AA");
it.line(0, 16, 128, 16);
it.printf(4, 18, id(roboto), "Mode: Auto L-M-H");
it.printf(4, 32, id(roboto), "Fan: %s (%d%%)", id(speed_name).c_str(), int(id(fan_speed) * 100));
it.printf(4, 46, id(roboto), "PM 2.5: %d ug", id(particulate_2_5));
- id: manual_0_100_page
lambda: |-
it.rectangle(0, 0, 128, 64);
it.print(4, 1, id(roboto), "FUME EXTRACTOR");
it.printf(112, 2, id(icon_font), "%s", id(wifi_connection) ? "\U000F05A9" : "\U000F05AA");
it.line(0, 16, 128, 16);
it.printf(4, 18, id(roboto), "Mode: Manual 0-100%%");
it.printf(4, 32, id(roboto), "Fan: %d%%", int(id(fan_speed) * 100));
it.printf(4, 46, id(roboto), "PM 2.5: %d ug", id(particulate_2_5));
- id: manual_l_m_h_page
lambda: |-
it.rectangle(0, 0, 128, 64);
it.print(4, 1, id(roboto), "FUME EXTRACTOR");
it.printf(112, 2, id(icon_font), "%s", id(wifi_connection) ? "\U000F05A9" : "\U000F05AA");
it.line(0, 16, 128, 16);
it.printf(4, 18, id(roboto), "Mode: Manual L-M-H");
it.printf(4, 32, id(roboto), "Fan: %s (%d%%)", id(speed_name).c_str(), int(id(fan_speed) * 100));
it.printf(4, 46, id(roboto), "PM 2.5: %d ug", id(particulate_2_5));
- id: off_page
lambda: |-
it.print(32, 1, id(roboto_large), "OFF");
it.printf(2, 32, id(roboto), "Long press knob to");
it.printf(2, 46, id(roboto), "turn on.");
select:
- platform: template
name: "${friendly_devicename}: Menu"
id: menu_option_ha
optimistic: true
options:
- "Standby"
- "Auto 0-100%"
- "Auto L-M-H"
- "Manual 0-100%"
- "Manual L-M-H"
initial_option: "Standby"
on_value:
then:
- lambda: |-
if (id(device_on_off)) {
// Get active menu index in HA and set it on device
auto index = id(menu_option_ha).active_index();
id(menu_index) = index.value() - 1; //Subtract 1 as select index in HA starts from 1
id(menu_change).execute();
}
number:
- platform: template
name: "${friendly_devicename}: Fan Speed"
id: fan_speed_ha
icon: "mdi:speedometer"
optimistic: true #Not sure what this does, research. leave? remove?
min_value: 0
max_value: 100
step: ${manual_fan_speed_step}
on_value:
then:
- lambda: |-
if (id(menu_index) == 3) {
id(pwm_man_speed) = int(id(fan_speed_ha).state);
id(report_fan_speed).publish_state(int(id(fan_speed_ha).state));
};
- platform: template
name: "${friendly_devicename}: Max Particulate"
id: max_particulate_ha
optimistic: true #Not sure what this does, research. leave? remove?
min_value: 50
max_value: 500
step: 5
on_value:
then:
- lambda: |-
id(max_particulate) = int(id(max_particulate_ha).state);
switch:
- platform: restart
name: "${friendly_devicename}: Restart"
- platform: template
name: "${friendly_devicename}"
id: device_on_off_ha
optimistic: true
turn_on_action:
- globals.set:
id: device_on_off
value: 'true'
- script.execute: device_turn_on
turn_off_action:
- globals.set:
id: device_on_off
value: 'false'
- script.execute: device_turn_off
button:
- platform: safe_mode
name: "${friendly_devicename}: Restart (Safe Mode)"
binary_sensor:
- platform: gpio
id: device_button
internal: true
pin:
number: 27
inverted: true
mode:
input: true
pullup: true
# This debounce filter causes many button presses to be missed ?!?!
# filters:
# - delayed_on: 10ms
on_multi_click:
# Single Long Click
- timing:
- ON for 1.0s to 3.0s
- OFF for at least 0.2s
then:
- logger.log: "Single Long Click"
- switch.toggle: device_on_off_ha
#- script.execute: display_on
# Single Short Click
- timing:
- ON for at most 0.8s
- OFF for at least 0.1s
then:
- logger.log: "Single Short Click"
- lambda: |-
if (id(device_on_off)) {
id(menu_change).execute();
id(update_menu_ha).execute();
} else {
id(display_on).execute();
id(display_off_again).execute();
}
output:
- platform: ledc
pin: 26
frequency: 19531 Hz
id: fan_pwm
- platform: gpio
id: pms5003_sensor
pin:
number: 4
mode:
output: true
sensor:
- platform: wifi_signal
name: "${friendly_devicename}: WiFi Signal"
update_interval: ${update_interval_wifi}
device_class: signal_strength
- platform: pulse_counter
id: rpm
internal: true
pin: 23
accuracy_decimals: 0
update_interval: "5s"
on_value:
- globals.set:
id: fan_rpm
value: !lambda 'return id(rpm).state / 2;'
- platform: template
id: report_fan_speed
name: "${friendly_devicename}: Fan Speed"
icon: "mdi:speedometer"
unit_of_measurement: "%"
accuracy_decimals: 0
update_interval: never
#update_interval: ${update_interval_s}
#lambda: return id(fan_speed) * 100;
- platform: template
id: report_fan_rpm
name: "${friendly_devicename}: Fan RPM"
icon: "mdi:speedometer"
accuracy_decimals: 0
update_interval: never
#update_interval: ${update_interval_s}
#lambda: return id(fan_rpm).state;
- platform: rotary_encoder
id: encoder
internal: true
pin_a:
number: 32
mode:
input: true
pullup: true
pin_b:
number: 25
mode:
input: true
pullup: true
on_clockwise:
#- logger.log: "Manual Speed Increase"
- lambda: |-
if (id(device_on_off)) {
if (id(menu_index) == 3) {
if (id(pwm_man_speed) >= 100) {
id(pwm_man_speed) = 100;
} else {
id(pwm_man_speed) += ${manual_fan_speed_step};
}
// Set fan_speed_ha to pwm_man_speed
id(update_fan_speed_ha).execute();
}
if (id(menu_index) == 4) {
if (id(speed_index) >= 2) {
id(speed_index) = 2;
} else {
id(speed_index) += 1;
}
id(set_speed_level).execute();
}
} else {
id(display_on).execute();
id(display_off_again).execute();
}
on_anticlockwise:
#- logger.log: "Manual Speed Decrease"
- lambda: |-
if (id(device_on_off)) {
if (id(menu_index) == 3) {
if (id(pwm_man_speed) <= 0) {
id(pwm_man_speed) = 0;
} else {
id(pwm_man_speed) -= ${manual_fan_speed_step};
}
// Set fan_speed_ha to pwm_man_speed
id(update_fan_speed_ha).execute();
}
if (id(menu_index) == 4) {
if (id(speed_index) <= 0) {
id(speed_index) = 0;
} else {
id(speed_index) -= 1;
}
id(set_speed_level).execute();
}
} else {
id(display_on).execute();
id(display_off_again).execute();
}
filters:
- delta: 1.0
- platform: pmsx003
type: PMSX003
pm_1_0:
id: fumes_particulate_1
on_value:
- globals.set:
id: particulate_1
value: !lambda 'return id(fumes_particulate_1).state;'
pm_2_5:
id: fumes_particulate_2_5
on_value:
- globals.set:
id: particulate_2_5
value: !lambda 'return id(fumes_particulate_2_5).state;'
pm_10_0:
id: fumes_particulate_10
on_value:
- globals.set:
id: particulate_10
value: !lambda 'return id(fumes_particulate_10).state;'
- platform: template
id: report_particulate_1
name: "${friendly_devicename}: Particulate <1.0µm"
lambda: return id(particulate_1);
device_class: pm1
unit_of_measurement: µg/m³
accuracy_decimals: 0
update_interval: never
- platform: template
id: report_particulate_2_5
name: "${friendly_devicename}: Particulate <2.5µm"
lambda: return id(particulate_2_5);
device_class: pm25
unit_of_measurement: µg/m³
accuracy_decimals: 0
update_interval: never
- platform: template
id: report_particulate_10
name: "${friendly_devicename}: Particulate <10.0µm"
device_class: pm10
unit_of_measurement: µg/m³
lambda: return id(particulate_10);
accuracy_decimals: 0
update_interval: never
interval:
- interval: 10s
then:
- lambda: |-
if (WiFi.status() == WL_CONNECTED) {
id(wifi_connection) = true;
} else {
id(wifi_connection) = false;
}
- interval: 1s
then:
lambda: |-
switch (id(menu_index)) {
case 0:
id(fan_speed) = 0.0;
break;
case 1:
if (id(fumes_particulate_2_5).state < 12.0) {
id(fan_speed) = 0.1;
}
else if ((id(fumes_particulate_2_5).state > 12.0) and (id(fumes_particulate_2_5).state <= 35.0)) {
id(fan_speed) = 0.2;
}
else if ((id(fumes_particulate_2_5).state > 35.0) and (id(fumes_particulate_2_5).state <= 55.0)) {
id(fan_speed) = 0.3;
}
else if ((id(fumes_particulate_2_5).state > 55.0) and (id(fumes_particulate_2_5).state <= 150.0)) {
id(fan_speed) = 0.4;
}
else if ((id(fumes_particulate_2_5).state > 150.0) and (id(fumes_particulate_2_5).state <= 250.0)) {
id(fan_speed) = 0.6;
}
else if ((id(fumes_particulate_2_5).state > 250.0) and (id(fumes_particulate_2_5).state <= 350.0)) {
id(fan_speed) = 0.8;
}
else if ((id(fumes_particulate_2_5).state > 350.0)) {
id(fan_speed) = 1.0;
ESP_LOGD("ALERT", "OVER 350µg/m³! Setting fan to 100");
}
break;
case 2:
if (id(fumes_particulate_2_5).state <= 55.0) {
id(fan_speed) = float(id(${low_speed}) / 100.0);
}
else if ((id(fumes_particulate_2_5).state > 55.0) and (id(fumes_particulate_2_5).state <= 250.0)) {
id(fan_speed) = float(id(${medium_speed}) / 100.0);
}
else if ((id(fumes_particulate_2_5).state > 250.0)) {
id(fan_speed) = float(id(${high_speed}) / 100.0);
ESP_LOGD("ALERT", "OVER 250µg/m³! Setting fan to full speed!");
}
break;
case 3:
id(fan_speed) = id(pwm_man_speed) / 100.0;
break;
case 4:
id(fan_speed) = id(man_speed) / 100.0;
break;
default:
break;
}
if(id(menu_index) != 0) {
id(fan_pwm).set_level(id(fan_speed));
id(report_fan_speed).publish_state(int(id(fan_speed) * 100));
id(report_fan_rpm).publish_state(int(id(fan_rpm)));
id(report_particulate_1).publish_state(id(particulate_1)); // Report Particulate Sensor
id(report_particulate_2_5).publish_state(id(particulate_2_5)); // Report Particulate Sensor
id(report_particulate_10).publish_state(id(particulate_10)); // Report Particulate Sensor
ESP_LOGD("PWM", "PWM: %d%%" , int(id(fan_speed) * 100));
//ESP_LOGD("PWM", "PARTICULATE THRESHOLD: %d" , int(id(max_fan_speed_particulate)));
}
//ESP_LOGD("DEBUG", "Device ON_OFF? %d" , id(device_on_off));
//ESP_LOGD("DEBUG", "Active Menu: %d", id(menu_index));
script:
- id: menu_change
then:
- lambda: |-
if (id(device_on_off)) {
if (id(menu_index) == 4) {
id(menu_index) = 0;
} else {
id(menu_index) += 1;
}
switch (id(menu_index)) {
case 0:
//Standby
id(reset_everything_to_default).execute();
break;
case 1:
//Auto 0-100%
id(pms5003_sensor).turn_on();
break;
case 2:
//Auto L-M-H
id(speed_index) = 0;
id(set_speed_level).execute();
id(pms5003_sensor).turn_on();
break;
case 3:
//Manual 0-100%
id(pwm_man_speed) = int(id(fan_speed_ha).state); // Set speed to what it is in HA
id(pms5003_sensor).turn_on();
break;
case 4:
//Manual L-M-H
id(speed_index) = 0;
id(set_speed_level).execute();
id(pms5003_sensor).turn_on();
break;
default:
//Undefined
id(pms5003_sensor).turn_off();
break;
}
ESP_LOGD("DEBUG", "Button Press: Menu: %d", id(menu_index));
}
- display.page.show: !lambda |-
switch (id(menu_index)) {
case 0:
//Standby
return id(standby_page);
break;
case 1:
//Auto 0-100%
return id(auto_0_100_page);
break;
case 2:
//Auto L-M-H
return id(auto_l_m_h_page);
break;
case 3:
//Manual 0-100%
return id(manual_0_100_page);
break;
case 4:
//Manual L-M-H
return id(manual_l_m_h_page);
break;
default:
//Undefined
return id(standby_page);
break;
}
# Updates the HA menu to match the device menu
- id: update_menu_ha
then:
- lambda: |-
auto call = id(menu_option_ha).make_call();
call.set_index(id(menu_index));
call.perform();
# Updates the HA fan speed to match the device fan speed
- id: update_fan_speed_ha
then:
- lambda: |-
auto call = id(fan_speed_ha).make_call();
call.set_value(id(pwm_man_speed));
call.perform();
# Updates max particulate in HA to setting (gvar) that is saved to flash
- id: set_max_particulate_ha
then:
- lambda: |-
auto call = id(max_particulate_ha).make_call();
call.set_value(id(max_particulate));
call.perform();
- id: set_speed_level
then:
- lambda: |-
switch (id(speed_index)) {
case 0:
id(speed_name) = "Low";
id(man_speed) = id(${low_speed});
break;
case 1:
id(speed_name) = "Medium";
id(man_speed) = id(${medium_speed});
break;
case 2:
id(speed_name) = "High";
id(man_speed) = id(${high_speed});
break;
default:
id(speed_name) = "Error";
break;
}
- id: reset_everything_to_default
then:
- lambda: |-
id(fan_pwm).set_level(0.0); // Turn off fan - only needed when switching modes after fan was on
id(report_fan_speed).publish_state(0); // Reset Fan Speed
id(report_fan_rpm).publish_state(0); // Reset Fan RPM
id(particulate_1) = 0; // Reset Global Variables
id(particulate_2_5) = 0; // Reset Global Variables
id(particulate_10) = 0; // Reset Global Variables
id(report_particulate_1).publish_state(0); // Report Particulate Sensor
id(report_particulate_2_5).publish_state(0); // Report Particulate Sensor
id(report_particulate_10).publish_state(0); // Report Particulate Sensor
id(pms5003_sensor).turn_off(); // Put sensor to sleep to preserve its life
- id: device_turn_on
then:
- lambda: |-
auto index = id(menu_option_ha).active_index(); // Get active menu index in HA
id(menu_index) = index.value() - 1; //Subtract 1 as select index start from 1
id(menu_change).execute();
id(device_display).turn_on();
- id: device_turn_off
then:
- lambda: |-
id(menu_index) = 0; // Default back to Standby menu on device
id(update_menu_ha).execute(); // Update menu in HA to reflect device
id(reset_everything_to_default).execute();
- display.page.show: off_page
- delay: 10s
- lambda: |-
if (!id(device_on_off)) {
id(device_display).turn_off();
}
- id: display_on
then:
- lambda: |-
if (!id(device_on_off)) {
id(device_display).turn_on();
id(display_on_off) = true;
id(show_off_page).execute();
//ESP_LOGD("DEBUG", "display_on");
}
- id: display_off_again
then:
- delay: 10s
- lambda: |-
if (!id(device_on_off)) {
id(device_display).turn_off();
id(display_on_off) = false;
//ESP_LOGD("DEBUG", "display_off_again - device off");
}
- id: show_off_page
then:
- display.page.show: off_page
The code is likely very far from perfect as I am not a programmer. I was trying to be able to change any setting from both HA and the device with the changes being reflected immediately. The extractor has multiple modes including a standby mode and a soft off mode. It is still work in progress and there is a bug I just introduced that causes the menu to get out of sync so if you use this code, know that there is an annoying bug. On top of that, the button timing is pretty annoying… by making it faster response it got pickier too. Anyway that is for another thread.
EDIT: Updated YAML to fix the bugs. I think it works well now.