if this offer still stands, I’d love to take two, too!
Thanks for sharing! It works perfectly.
Hi do you still have any of these? Are they fully assembled? If so I’d be interested in purchasing one. Thanks!
Hi everyone,
I’m trying to get a fully assembled board, but it seems the rotary switch is out of stock. JLCPCB offered to source them for me at a cost of 5 x $18.73, which comes to $93.65.
Honestly, that seems a bit steep considering the in-stock price is $2 per piece.
Could anyone suggest a suitable replacement for this rotary switch in their store or recommend an alternative supplier with more reasonable pricing?
Thanks in advance for your help!
By the way, if anyone is interested in getting 2 of these boards, let me know!
I am on the way to IKEA to get a Förnuftig to build the first (non PWM version).
However, at this point I still have a minor problem understanding the logic.
The Förnuftig board uses 5V logic, but ESP8266 and ESP32 use 3.3V logic. 5V on a 3.3V pin without level shifter or voltage divider are usually not healthy.
Is my understanding correct that the ESP senses which pin is grounded via the rotary switch (top of the board/switch) and depending on that outputs a voltage to the cut legs/traces of the rotary switch on the bottom of the board/switch and then the IC on the Förnuftig board selects the highest input (whatever it is 3.3V or 5V), which is why it works without frying the ESP?
Or are the ESP pins actually exposed to 5V and I didn’t get it?
If it is all on 3.3V, why do I need a buck converter for 5V, if the logic is on 3.3V anyway and the 5V will be reduced by the ESP’s onboard voltage regulator to 3.3V (converting 1.7V into heat). Is there a reason why nobody uses a 30V(eq. 24V) to 3.3V buck converter?
Thanks! ![]()
[edit] ^^tested it - logic is 3.3V, with a 5V or a 3.3V buck converter. Since 3.3V works, I sticked to that one to save energy
[/edit]
Hey everyone,
I wanted to share my updated ESPHome configuration for the IKEA FORNÜFTIG Air Purifier while keeping the factory PCB in place. Many thanks to everyone who contributed code and ideas!
Main Changes
- ADC Reading: Now checks the filter LED status at the specified
sensor_update_intervalinstead of every 100 ms—no need to hammer the ADC for an LED that stays lit for hours. - Energy Sensor Filters: Added filtering to the
total_daily_energysensor for smoother readings. - Fan State Restoration: The purifier’s fan speed is now restored automatically after reboot.
Important Note on Energy Monitoring
The total_daily_energy sensor resets on ESP reboots and at midnight. For consistent daily tracking, create a Utility Meter in Home Assistant and enable periodic resetting.
Below is my YAML configuration.
GitHub: office-IKEA-Fornuftig-air-purifier.yaml
(Note: This code references shared modules in my common folder for Wi-Fi and other common components.)
##############################################################
# 🏠 Richard Huish 2025
# 📌 ESPHome Configuration - IKEA FORNÜFTIG Air Purifier Control
# 📂 Repository: https://github.com/genestealer/Home-Assistant-Configuration
#
# 📝 Description:
# This ESPHome configuration allows control of an IKEA FORNÜFTIG Air Purifier
# using an ESP8266 (NodeMCU v2) while retaining the original PCB.
#
# ✅ Fan speed control (OFF, Low, Medium, High)
# ✅ Monitoring filter status via LED detection (ADC input)
# ✅ Measuring power consumption per fan speed
# ✅ Integration with Home Assistant for remote control
# ✅ Can be powered from the purifier’s 24V DC supply via a DC-DC step-down module
#
##############################################################
# 🔩 HARDWARE
##############################################################
# Main Components:
# - ESP8266 (NodeMCU v2) - Wi-Fi microcontroller
# - IKEA FORNÜFTIG Air Purifier (Factory PCB retained)
# - DC-DC Step-Down Converter (e.g., LM2596 / Mini-360)
# - Steps down the IKEA purifier’s 24V DC to 3.3V (or 5V for VIN pin)
# - Connects to the ESP8266 for power supply
# - Analog-to-Digital Converter (ADC) for filter LED monitoring
# - GPIO-based fan control using relays or direct switching
#
##############################################################
# 🛠 WIRING & PCB MODIFICATIONS
##############################################################
# Powering the ESP8266:
# - The IKEA FORNÜFTIG air purifier operates at 24V DC.
# - Use a DC-DC converter to step down 24V to 3.3V (or 5V for VIN pin on the ESP8266).
#
# Rotary Switch (Manual Control) Modifications:
# - Cut the 5 pins connecting the rotary switch to the PCB.
# - The ESP8266 will read manual switch positions while overriding fan speed control.
#
# Connections to ESP8266:
# - Filter LED Detection (ADC Input):
# - A0 → Positive side of the IKEA filter LED
#
# - Fan Speed Outputs (ESP to IKEA PCB):
# - D2 → High Speed (PCB Pin 2)
# - D3 → Medium Speed (PCB Pin 3)
# - D4 → Low Speed (PCB Pin 4)
#
# - Rotary Switch Inputs (Manual Mode from User):
# - D1 → Off Position
# - D5 → Low Speed
# - D6 → Medium Speed
# - D7 → High Speed
#
# - GND Connections:
# - Two end pins of the rotary switch → ESP GND
#
# This allows both manual control and ESPHome automation!
#
##############################################################
# 🔄 Substitutions for Easy Configuration
substitutions:
# 📛 Naming
name: office-air-purifier
friendly_name: "Office FORNÜFTIG Air Purifier"
ui_comment: "IKEA FORNÜFTIG Air Purifier Control"
# 🌐 Network Configuration (Static IP)
ip: 192.168.2.49
gateway: 192.168.2.1
subnet: 255.255.255.0
# 🔴 Status LED Configuration
status_led: D0 # GPIO16 - NodeMCU onboard LED
status_led_inverted: "true"
# 🛠 Debug Logging Level
log_level: DEBUG
# 📦 Project Metadata
project_name: "genestealer.IKEA-Fornuftig-air-purifier"
project_version: "1.0.0"
package_import_url: "github://genestealer/Home-Assistant-Configuration/esphome/office-IKEA-Fornuftig-air-purifier.yaml"
# 📊 Sensor Update Interval
sensor_update_interval: 60s
##############################################################
# 🚀 ESP8266 Board Configuration
##############################################################
esp8266:
board: nodemcuv2
##############################################################
# 🔗 Import Shared Code (Common Components)
##############################################################
packages:
device_base: !include common/device_base.yaml
wifi: !include common/device_base_wifi.yaml
# Uncomment below to remove the status LED:
# status_led: !remove
# 🌍 Dashboard Import Settings
dashboard_import:
package_import_url: ${package_import_url}
import_full_config: false # Import only necessary sections
##############################################################
# 🏠 Home Assistant Integration
##############################################################
esphome:
on_boot:
priority: -100.0
then:
- lambda: |-
float power = 0.5; // Default power usage when fan is off
if (id(fan_internal).speed == 1) power = 1.5;
else if (id(fan_internal).speed == 2) power = 6.5;
else if (id(fan_internal).speed == 3) power = 15;
id(power_usage).publish_state(power);
##############################################################
# 🌡️ Sensor Configuration (Filter Status & Power Usage)
##############################################################
sensor:
# 🏷️ Filter LED Status (Detects when filter needs changing)
- platform: adc
pin: A0
internal: true
id: led_internal
update_interval: ${sensor_update_interval}
accuracy_decimals: 1
samples: 10
filters:
- delta: 0.1
on_value:
if:
condition:
lambda: 'return x < 0.2;'
then:
- binary_sensor.template.publish:
id: led_sensor
state: OFF
else:
- binary_sensor.template.publish:
id: led_sensor
state: ON
# ⚡ Power Consumption Based on Fan Speed
- platform: template
name: "Power Usage"
id: power_usage
device_class: power
state_class: measurement
unit_of_measurement: W
update_interval: ${sensor_update_interval}
# 🔋 Total Daily Energy Consumption (Resets at Midnight)
# Create a Utility Meter in Home Assistant and ensure periodic resetting is enabled
# This value resets on ESP reboot and at midnight.
- platform: total_daily_energy
name: 'Air Purifier Energy Today'
id: sensor_total_daily_energy
power_id: power_usage
unit_of_measurement: 'kWh'
icon: mdi:circle-slice-3
state_class: total_increasing
device_class: energy
accuracy_decimals: 3
filters:
- throttle: ${sensor_update_interval} # Limit update rate
- multiply: 0.001 # Convert W to kW (1 W = 0.001 kW)
- delta: 0.01 # Only update if energy changes by 0.01 kWh (10W)
- heartbeat: ${sensor_update_interval} # Ensure at least one update per interval
##############################################################
# ⏳ Time Component (Used for Resetting Energy Meter at Midnight)
##############################################################
time:
- platform: homeassistant
id: homeassistant_time # Sync time with HA for accurate resets
##############################################################
# 🚪 Binary Sensors (Filter Status & Fan Speed Buttons)
##############################################################
binary_sensor:
# 🚨 Filter Status LED
- platform: template
id: led_sensor
name: "Filter Status"
device_class: problem
# 🛠 Fan Control Dial (Reads Physical Rotary Positions)
- platform: gpio
internal: true
id: in_d1
name: "Fan Off"
pin:
number: D1
mode: INPUT_PULLUP
inverted: True
on_press:
- fan.turn_off:
id: fan_internal
- platform: gpio
internal: true
id: in_d5
name: "Fan Low Speed"
pin:
number: D5
mode: INPUT_PULLUP
inverted: True
on_press:
- fan.turn_on:
id: fan_internal
speed: 1
- platform: gpio
internal: true
id: in_d6
name: "Fan Medium Speed"
pin:
number: D6
mode: INPUT_PULLUP
inverted: True
on_press:
- fan.turn_on:
id: fan_internal
speed: 2
- platform: gpio
internal: true
id: in_d7
name: "Fan High Speed"
pin:
number: D7
mode: INPUT_PULLUP
inverted: True
on_press:
- fan.turn_on:
id: fan_internal
speed: 3
##############################################################
# ⚙️ Fan Speed Control Outputs (ESP Overrides Rotary Switch)
##############################################################
output:
- platform: gpio
pin: D2
id: gpio_d2
inverted: true
- platform: gpio
pin: D3
id: gpio_d3
inverted: true
- platform: gpio
pin: D4
id: gpio_d4
inverted: true
- platform: template
id: fan_out_internal
type: float
write_action:
- lambda: |-
if (state < 0.1) {
id(gpio_d2).turn_off();
id(gpio_d3).turn_off();
id(gpio_d4).turn_off();
id(power_usage).publish_state(0.5);
} else if (state < 0.4) {
id(gpio_d4).turn_on();
id(gpio_d3).turn_off();
id(gpio_d2).turn_off();
id(power_usage).publish_state(1.5);
} else if (state < 0.7) {
id(gpio_d4).turn_off();
id(gpio_d3).turn_on();
id(gpio_d2).turn_off();
id(power_usage).publish_state(6.5);
} else {
id(gpio_d4).turn_off();
id(gpio_d3).turn_off();
id(gpio_d2).turn_on();
id(power_usage).publish_state(15);
}
##############################################################
# 🌬️ Fan Component
##############################################################
fan:
- platform: speed
output: fan_out_internal
id: fan_internal
name: "Air Purifier Fan"
speed_count: 3
restore_mode: RESTORE_DEFAULT_OFF
I was just about to start another thread about my mod on the purifier but I just describe it here. I didn’t know about this thread when I started so I worked from scratch. I will attach a picture of the schematic that I traced out, maybe it’s useful for someone.
I ended up making my own PCB with ESP32 D1 Mini on it. Re-used the rotary switch, filter LED & switch + connectors for fan and power input. The circuit is similar to the original board with a few differences or improvements.
Power switching to the fan is controlled by the ESP32 but it’s also controlled by the rotary switch in hardware so that when the switch is OFF, ESP cannot turn the fan on in fault condition. RPM of the fan is read and displayed in HA. I still haven’t implemented a filter change alarm and will have to read through this thread for ideas.
There has been some confusion about the IO voltage levels and I couldn’t find any information on the fan motor about the voltage level it accepts on the RPM input. But I measured the levels and the original board uses 5V µC and so drives the fan with 5V levels. RPM outputs from a fan are usually open collector and I tested that 3.3V works fine on the speed control input as well as when reading from the fan (3.3V pull-up).
For those wondering the original board drives the fan as follows:
- Speed 1: 100 Hz
- Speed 2: 220 Hz
- Speed 3: 300 Hz
I have tested that you can drive it lower but the limit is about 50 Hz or so and not very useful under 100 Hz. 300 Hz is the full speed and it seems it is the maximum speed of the fan. Also it’s a good idea to switch off the fan power completely and not just set the frequency to (near) zero. The fan may keep running at a very low speed or twitch.
As for the FG (RPM) output from the fan, it outputs the same frequency square wave. I measured the true RPM using a tachometer and the output is about 8 rpm/Hz, so 100 Hz = 800 rpm.
In YAML I used this code to get RPM reading into HA:
sensor:
- platform: pulse_counter
pin:
number: GPIO23
mode:
input: true
pullup: true
unit_of_measurement: 'rpm'
name: 'Fan rpm'
id: fan_rpm
count_mode:
rising_edge: INCREMENT
falling_edge: DISABLE
update_interval: 5s
accuracy_decimals: 0
filters:
- multiply: 0.133 # Förnuftig fan motor rpm output is about 8 rpm/pulse so 100 Hz CLK equals 800 rpm.
The input needs a pullup, external or internal. And there is no voltage on it without pullup so it is open collector and can be used in ESP’s 3.3 V input. I added a series resistor and a small capacitor to ground like in the original board. Those filter out possible rogue spikes.
The whole circuit works at 3.3V because there is no point in feeding the ESP32 5V when it steps it down to 3.3V anyway. So I used an MP1584 module to convert the 24V to 3.3V.
You can see from the picture that I the D1 Mini is soldered directly on the PCB. That is because the cover will not fit if I used a socket. Downside is it’s soldered in place and a pain to replace if needed.
This design keeps the ESP32 active all the time and the rotary switch only tells it to switch the fan on and off. This prevents disconnects in HA. Current fan setting is saved so that if the power is cut off, the fan returns to the setting corresponding to the rotary switch.
Oh, and the rotary switch can be found on AE, type is RS17 rotary switch (4 positions, 1 pole, 90 degrees rotation). The fan and power connectors are JST XHB (or a similar ).
Finished custom board:
Original board schematic:
The speeds for each of the rotary switch positions can be set in HA as well as some settings:
Do you still have any by any chance?
Thanks for that really great instructions.
I modded my FÖRNUFTIG according to the original instructions (Meaning: Not directly changing PMW but smartifing the rotary switch). Unfortunatly, I encountered some problems while building this.
- I wired the ESP, before flashing the firmware. I decided to solder everything instead of using header pins. Pulling D3 to GND with a wire did not work for me. As I desoldered D1-D4, it worked again, subesquent OTA updates worked anyways. But if you ever tried to get a wire through a hole that had been soldered before … I guess you now know my pain
- I managed to rip the pad, that is now wired to D2, out of the PCB. I replaced the path with a wire and wired D2 to R4
- For whatever reason that I wasn’t able to find out (I have my multimeter at my other home, so i’m limited in diagnostics), only the low setting works. The switch works fine, but it seems that my added wire is working better than my solders to the original solder pads.
I have 3 of those and it is awesome.
2 with esphome controling the 4 step thingy
and one new ready to try the pwm control
A bit OT but i just made some 3D models for the air outlet and I think you coud use that when like ganging it somewhere and turning it on remotely and want to duct the clean air somewhere else. ![]()
Im modify my by instructions in first post and ESPHome part and controlling by Home Assistant work ok, but have issues with knob controll.
When I controll it by knob:
0->1 - Normal Speed 1
1->2 - Full speed 3
2->3 - Still Full speed 3
3->2 - Speed 2
2->1 - Speed 1
1->0 - Full speed 3
@Mortalitas you have similar issue. Did you solve issue?
What pins/legs/traces need to be cut? Im cut only 4 legs on knob on which I have soldered wires…
Additional info:
Pos 0: D7 is connected to GND
Pos 1: D7 AND D6 is connected to GND
Pos 2: D5 is connected to GND
Pos 3: D1 is connected to GND
ESPHome Logs:
Position 0 → 1:
[12:52:52.640][D][binary_sensor:039]: 'in_d1': New state is OFF
[12:52:52.664][D][fan:021]: 'Fan' - Setting:
[12:52:52.679][D][fan:024]: State: ON
[12:52:52.679][D][fan:030]: Speed: 3
[12:52:52.680][D][sensor:131]: 'Power consumption': Sending state 15.00000 W with 1 decimals of accuracy
[12:52:52.680][D][sensor:131]: 'Consumed Energy': Sending state 0.00089 Wh with 3 decimals of accuracy
[12:52:52.700][D][fan:129]: 'Fan' - Sending state:
[12:52:52.701][D][fan:130]: State: ON
[12:52:52.701][D][fan:132]: Speed: 3
[12:52:52.701][D][binary_sensor:039]: 'in_d7': New state is ON
[12:52:52.701][D][binary_sensor:039]: 'in_d7': New state is OFF
[12:52:52.714][D][fan:021]: 'Fan' - Setting:
[12:52:52.714][D][fan:024]: State: ON
[12:52:52.714][D][fan:030]: Speed: 1
[12:52:52.726][D][sensor:131]: 'Power consumption': Sending state 1.50000 W with 1 decimals of accuracy
[12:52:52.726][D][sensor:131]: 'Consumed Energy': Sending state 0.00111 Wh with 3 decimals of accuracy
[12:52:52.737][D][fan:129]: 'Fan' - Sending state:
[12:52:52.738][D][fan:130]: State: ON
[12:52:52.738][D][fan:132]: Speed: 1
[12:52:52.741][D][binary_sensor:039]: 'in_d5': New state is ON
Position 1 → 2:
[12:53:33.388][D][binary_sensor:039]: 'in_d5': New state is OFF
[12:53:33.419][D][fan:021]: 'Fan' - Setting:
[12:53:33.431][D][fan:024]: State: ON
[12:53:33.431][D][fan:030]: Speed: 2
[12:53:33.431][D][sensor:131]: 'Power consumption': Sending state 6.50000 W with 1 decimals of accuracy
[12:53:33.431][D][sensor:131]: 'Consumed Energy': Sending state 0.01807 Wh with 3 decimals of accuracy
[12:53:33.448][D][fan:129]: 'Fan' - Sending state:
[12:53:33.448][D][fan:130]: State: ON
[12:53:33.448][D][fan:132]: Speed: 2
[12:53:33.452][D][binary_sensor:039]: 'in_d6': New state is ON
[12:53:33.493][D][fan:021]: 'Fan' - Setting:
[12:53:33.505][D][fan:024]: State: ON
[12:53:33.505][D][fan:030]: Speed: 3
[12:53:33.505][D][sensor:131]: 'Power consumption': Sending state 15.00000 W with 1 decimals of accuracy
[12:53:33.505][D][sensor:131]: 'Consumed Energy': Sending state 0.01820 Wh with 3 decimals of accuracy
[12:53:33.519][D][fan:129]: 'Fan' - Sending state:
[12:53:33.519][D][fan:130]: State: ON
[12:53:33.519][D][fan:132]: Speed: 3
[12:53:33.528][D][binary_sensor:039]: 'in_d7': New state is ON
Position 2 → 3:
[12:54:31.548][D][binary_sensor:039]: 'in_d6': New state is OFF
Position 3 → 2:
[12:55:13.687][D][fan:021]: 'Fan' - Setting:
[12:55:13.704][D][fan:024]: State: ON
[12:55:13.704][D][fan:030]: Speed: 2
[12:55:13.704][D][sensor:131]: 'Power consumption': Sending state 6.50000 W with 1 decimals of accuracy
[12:55:13.704][D][sensor:131]: 'Consumed Energy': Sending state 0.43568 Wh with 3 decimals of accuracy
[12:55:13.716][D][fan:129]: 'Fan' - Sending state:
[12:55:13.716][D][fan:130]: State: ON
[12:55:13.716][D][fan:132]: Speed: 2
[12:55:13.716][D][binary_sensor:039]: 'in_d6': New state is ON
Position 2 → 1:
[12:55:49.247][D][binary_sensor:039]: 'in_d7': New state is OFF
[12:55:49.336][D][binary_sensor:039]: 'in_d6': New state is OFF
[12:55:49.352][D][fan:021]: 'Fan' - Setting:
[12:55:49.352][D][fan:024]: State: ON
[12:55:49.352][D][fan:030]: Speed: 1
[12:55:49.352][D][sensor:131]: 'Power consumption': Sending state 1.50000 W with 1 decimals of accuracy
[12:55:49.361][D][sensor:131]: 'Consumed Energy': Sending state 0.50007 Wh with 3 decimals of accuracy
[12:55:49.361][D][fan:129]: 'Fan' - Sending state:
[12:55:49.376][D][fan:130]: State: ON
[12:55:49.376][D][fan:132]: Speed: 1
[12:55:49.376][D][binary_sensor:039]: 'in_d5': New state is ON
Position 1 → 0:
[12:56:35.774][D][binary_sensor:039]: 'in_d5': New state is OFF
[12:56:35.818][D][fan:021]: 'Fan' - Setting:
[12:56:35.831][D][fan:024]: State: OFF
[12:56:35.831][D][sensor:131]: 'Power consumption': Sending state 0.10000 W with 1 decimals of accuracy
[12:56:35.831][D][sensor:131]: 'Consumed Energy': Sending state 0.51943 Wh with 3 decimals of accuracy
[12:56:35.831][D][fan:129]: 'Fan' - Sending state:
[12:56:35.840][D][fan:130]: State: OFF
[12:56:35.840][D][fan:132]: Speed: 1
[12:56:35.840][D][binary_sensor:039]: 'in_d1': New state is ON
[12:56:35.852][D][fan:021]: 'Fan' - Setting:
[12:56:35.852][D][fan:024]: State: ON
[12:56:35.852][D][fan:030]: Speed: 3
[12:56:35.865][D][sensor:131]: 'Power consumption': Sending state 15.00000 W with 1 decimals of accuracy
[12:56:35.880][D][sensor:131]: 'Consumed Energy': Sending state 0.51943 Wh with 3 decimals of accuracy
[12:56:35.880][D][fan:129]: 'Fan' - Sending state:
[12:56:35.880][D][fan:130]: State: ON
[12:56:35.885][D][fan:132]: Speed: 3
[12:56:35.885][D][binary_sensor:039]: 'in_d7': New state is ON
[12:56:35.892][D][binary_sensor:039]: 'in_d7': New state is OFF
@horvathgergo, would you be so kind to share some more detailed instructions how to order the PCB boards at JLCPCB? I’m not an expert on PCB but I do want to make my Fornuftig airfilters smart and add them to homeassistant.
If others have a way of helping to order PCB boards to make the airfilters smart, please chime in!
Many thanks in advance!
You can upload the Gerber files (see the GitHub repo release) to a PCB manufacturer’s website and keep the default board settings. Then hand-solder the components or have them soldered by a professional.
You can also use the BOM and CPL if you want automated assembly, but it’s a bit tricky because some components are not available at JLC anymore, so you may need to look for alternatives.
Thanks a lot @horvathgergo for taking the time to do this. I’m pretty sure I’m not able to do this on my own but I will try and find some help or support! But appreciate all the time you have spend on this!
Thanks for the designs, I printed the 100mm duct version, but there are holes in it. Looks like the design has some gaps just before the tube clamp area
Hello there,
Just chiming in to say thank you for posting this project, and to share my own result on it!
I ended up accidentally ordering an ESP32 D1 Mini, rather than the 8266 model of the original. Luckily, the ESP32 variant is designed to share the same shields, so the pin translation is easy:
TL;DR:
- GND=GND
- 5V=VCC
- D1=GPOI22
- D2=GPIO21
- D3=GPIO17
- D4=GPIO16
- D5=GPIO18
- D6=GPIO19
- D7=GPIO23
Actually installing is was easy enough: I had some old solder boards, so I cut one up to mount the microcontroller, and used an existing mounting screw to hold it in place. The solder board is only being used to secure the microcontroller; you can see one of the two soldered points (all the way on the left) in this top view:
Instead of cutting the legs on the rotary encoder, I desoldered the entire thing, bent the pins up, and wired them with heat shrink for a cleaner look:
Here it is, fully assembled and tidied before I closed it up:
(The DC-DC buck converter is secured with some double-sided silicone mounting tape; it felt better to give it a little breathing room, rather than shove it behind the other boards at the top of the device).
Finally, here’s my modified code for the ESP32 version, to reflect the different GPIO pins used to achieve the same result. I’ve kept the “in_d1” ids to make things more intelligible for anyone using the original code, but updated the physical pin assignments used for an ESP32-based D1 Mini:
esphome:
name: $devicename
friendly_name: FÖRNUFTIG
esp32:
variant: ESP32
substitutions:
devicename: esp-32-fornuftig
friendly_name: FORNÜFTIG
# Insert your SSID and Your PWD after inital setup
wifi:
networks:
- ssid: !secret wifi_ssid
password: !secret wifi_password
# Enable logging
logger:
logs:
# Make adc input less verbose
adc: INFO
# Enable Home Assistant API
api:
#password: xxxxxxxxxxx
encryption:
key: !secret esp32_fornuftig_encryption_key
# Enable OTA upgrade
ota:
- platform: esphome
password: xxxxxxxxxxx
safe_mode:
sensor:
# Read LED from adc input 10 times per second
- platform: adc
pin: A0
internal: true
id: led_internal
update_interval: 100ms
accuracy_decimals: 1
filters:
- delta: 0.1
on_value:
if:
condition:
lambda: 'return x < 0.2;'
then:
- binary_sensor.template.publish:
id: led_sensor
state: OFF
else:
- binary_sensor.template.publish:
id: led_sensor
state: ON
binary_sensor:
- platform: template
id: led_sensor
name: $friendly_name Filter
device_class: problem
- platform: gpio
internal: true
id: in_d1
pin:
number: GPIO22
mode: INPUT_PULLUP
inverted: true
on_press:
- fan.turn_off:
id: fan_internal
- platform: gpio
internal: true
id: in_d5
pin:
number: GPIO18
mode: INPUT_PULLUP
inverted: true
on_press:
- fan.turn_on:
id: fan_internal
speed: 1
- platform: gpio
internal: true
id: in_d6
pin:
number: GPIO19
mode: INPUT_PULLUP
inverted: true
on_press:
- fan.turn_on:
id: fan_internal
speed: 2
- platform: gpio
internal: true
id: in_d7
pin:
number: GPIO23
mode: INPUT_PULLUP
inverted: true
on_press:
- fan.turn_on:
id: fan_internal
speed: 3
output:
- platform: gpio
pin: GPIO21
id: gpio_d2
inverted: true
- platform: gpio
pin: GPIO17
id: gpio_d3
inverted: true
- platform: gpio
pin: GPIO16
id: gpio_d4
inverted: true
- platform: template
id: fan_out_internal
type: float
write_action:
if:
condition:
lambda: 'return state < 0.1;'
then:
- output.turn_off: gpio_d4
- output.turn_off: gpio_d3
- output.turn_off: gpio_d2
else:
- if:
condition:
lambda: 'return state < 0.4;'
then:
- output.turn_on: gpio_d4
- output.turn_off: gpio_d3
- output.turn_off: gpio_d2
else:
- if:
condition:
lambda: 'return state < 0.7;'
then:
- output.turn_off: gpio_d4
- output.turn_on: gpio_d3
- output.turn_off: gpio_d2
else:
- output.turn_off: gpio_d4
- output.turn_off: gpio_d3
- output.turn_on: gpio_d2
fan:
- platform: speed
output: fan_out_internal
id: fan_internal
name: "$friendly_name Fan"
speed_count: 3
Thanks again for such a fun project! It was very satisfying to integrate my new (and affordable) air purifier into my HA ecosystem.
After a few years, went back to my code and had it optimized thanks to AI ![]()
Here is a revised ESPHOME YAML for my build with the custom PCB v1.
Also changed the speed to 5 stages.
5-Stage Smart Control Firmware for IKEA Förnuftig (ESPHome)
This ESPHome firmware configuration is custom-built for the Simple Smart Control Panel PCB (v1) for the IKEA Förnuftig Air Purifier (Project Page on OSHWLab). It upgrades the basic manual air purifier into a 5-stage automated device fully integrated with Home Assistant.
Key Features
- Advanced 5-Stage Speed Tuning: Dynamically shifts the fan motor frequency (120 Hz to 300 Hz) to unlock precise, whisper-quiet operation without motor hum.
- Smart Rotary Encoder Engine: Implements advanced delta-tracking logic. Moving the wheel clockwise decreases speed down to Level 1, while anti-clockwise increases speed up to Level 5. Over-rotation triggers are safely blocked.
- Single-Button Toggle Control: Pressing the encoder button toggles the purifier cleanly between Stage 0 (Off) and Stage 1 (Min), completely synced across physical hardware, Home Assistant, and the web UI.
- Hardware-Synced Status LED: The status LED turns on instantly whenever the fan is active and turns off when the fan stops—regardless of whether it was triggered physically or via the Web/HA API.
- Real-Time Diagnostics: Monitors fan rotation (RPM) and provides near-instantaneous calculated power consumption (1-second intervals) for precise Home Assistant Energy Dashboard tracking.
Pin Mapping (Custom PCB v1)
| Hardware Component | Wemos D1 Mini Pin | ESPHome GPIO | Configuration |
|---|---|---|---|
| PWM Fan Control | D1 |
GPIO5 |
150 Hz Base / Phase-modulated |
| Pulse Counter (RPM) | D2 |
GPIO4 |
INPUT_PULLUP / 3s refresh |
| Status LED | D3 |
GPIO0 |
Binary Output |
| Rotary Encoder (CLK) | D5 |
GPIO14 |
Inverted Pullup |
| Rotary Encoder (DT) | D6 |
GPIO12 |
Inverted Pullup |
| Encoder Taster (SW) | D7 |
GPIO13 |
INPUT_PULLUP / 50ms debounced |
Flashing & Deployment
- Make sure you have ESPHome installed (either as a Home Assistant Add-on or via CLI).
- Download or copy the provided
esp82-fornuftig-hack.yamlconfiguration. - Replace the fallback credentials (
YOUR_WIFI_SSID,YOUR_WIFI_PASSWORD, andUSE_YOUR_OWN_SECURE_OTA_PASSWORD) with your actual network credentials. - Connect your Wemos D1 Mini via USB and run:
esphome run esp82-fornuftig-hack.yaml - Once flashed successfully, the device will automatically appear in Home Assistant as a native
Fanentity.
ESPHOME code:
# ==============================================================================
# IKEA FORNÜFTIG Air Purifier - 5-Stage Smart Hack (ESPHome)
# Features: Rotary Encoder control, Push-button Toggle, Pulse Counter RPM,
# Calculated Power & Energy Monitoring, Status LED, Sync'd Web/HA API
# ==============================================================================
substitutions:
devicename: esp82-fornuftig-hack
friendly_name: "IKEA FORNÜFTIG"
update_interval: 30s
io_username: "YOUR_ADAFRUIT_IO_USERNAME_IF_NEEDED"
io_key: "YOUR_ADAFRUIT_IO_KEY_IF_NEEDED"
wifissid: "YOUR_WIFI_SSID"
wifipassword: "YOUR_WIFI_PASSWORD"
esphome:
name: $devicename
esp8266:
board: d1_mini
wifi:
networks:
- ssid: ${wifissid}
password: ${wifipassword}
ap:
ssid: "${devicename}-fallback-ap"
password: "CHANGE_THIS_FALLBACK_PASSWORD" # Choose a secure fallback password
web_server:
port: 80
logger:
level: DEBUG
logs:
adc: INFO
gpio: INFO
api:
# password: "YOUR_HOME_ASSISTANT_API_PASSWORD_IF_NEEDED"
ota:
- platform: esphome
password: "USE_YOUR_OWN_SECURE_OTA_PASSWORD" # Replace with a fresh password hash/string
captive_portal:
globals:
- id: fan_stage
type: int
restore_value: yes
initial_value: "0"
- id: last_encoder_value
type: int
restore_value: no
initial_value: "0"
switch:
- platform: restart
name: "${friendly_name} Restart"
fan:
- platform: template
name: "${friendly_name} Lüfter"
id: fornuftig_fan
speed_count: 5
# Frequency modulation logic to adjust motor acoustics natively
on_speed_set:
- lambda: |-
auto p_out = id(pwm_output);
int current_speed = id(fornuftig_fan).speed;
if (current_speed == 1) { p_out->set_frequency(120.0f); p_out->set_level(0.50f); } // Stage 1: 120 Hz
if (current_speed == 2) { p_out->set_frequency(165.0f); p_out->set_level(0.50f); } // Stage 2: 165 Hz
if (current_speed == 3) { p_out->set_frequency(210.0f); p_out->set_level(0.50f); } // Stage 3: 210 Hz
if (current_speed == 4) { p_out->set_frequency(255.0f); p_out->set_level(0.50f); } // Stage 4: 255 Hz
if (current_speed == 5) { p_out->set_frequency(300.0f); p_out->set_level(0.50f); } // Stage 5: 300 Hz
# Synchronizes LED state and restores fan parameters on manual/web trigger
on_turn_on:
- lambda: |-
if (id(fan_stage) == 0) id(fan_stage) = 1;
id(fornuftig_fan).speed = id(fan_stage);
auto p_out = id(pwm_output);
auto led_call = id(status_led).turn_on();
led_call.perform();
if (id(fan_stage) == 1) { p_out->set_frequency(120.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 2) { p_out->set_frequency(165.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 3) { p_out->set_frequency(210.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 4) { p_out->set_frequency(255.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 5) { p_out->set_frequency(300.0f); p_out->set_level(0.50f); }
# Synchronizes global memory and disables PWM signal instantly
on_turn_off:
- lambda: |-
id(fan_stage) = 0;
auto led_call = id(status_led).turn_off();
led_call.perform();
id(pwm_output).set_level(0.00f);
sensor:
- platform: template
name: "$friendly_name - Power consumption"
id: fonuftig_power_consumption
device_class: power
state_class: measurement
unit_of_measurement: "W"
accuracy_decimals: 1
update_interval: 1s # High-frequency refresh for real-time dashboard responsiveness
lambda: |-
return (id(fan_stage) * 0.10) + 0.01;
- platform: integration
name: "$friendly_name - Consumed Energy"
sensor: fonuftig_power_consumption
time_unit: "h"
device_class: energy
state_class: total_increasing
unit_of_measurement: "Wh"
restore: false
integration_method: left
- platform: pulse_counter
pin:
number: D2
mode: INPUT_PULLUP
unit_of_measurement: "RPM"
accuracy_decimals: 0
id: fan_tach
name: "Fornuftig Fanspeed"
update_interval: 3s # Optimized balance for tracking motor RPM downshifts accurately
filters:
- multiply: 0.1
- platform: rotary_encoder
id: my_rotary_encoder
name: "Rotary Encoder"
min_value: -10000
max_value: 10000
filters:
- debounce: 50ms
pin_a:
number: D5
inverted: true
mode:
input: true
pullup: true
pin_b:
number: D6
inverted: true
mode:
input: true
pullup: true
# Direction detection engine based on relative encoder value delta tracking
on_value:
then:
- lambda: |-
int current_val = (int)x;
if (current_val != id(last_encoder_value)) {
if (current_val < id(last_encoder_value)) {
if (id(fan_stage) > 1) id(fan_stage) -= 1;
}
else {
if (id(fan_stage) < 5) id(fan_stage) += 1;
}
id(last_encoder_value) = current_val;
if (id(fan_stage) > 0) {
id(apply_fan_stage).execute();
}
}
script:
- id: apply_fan_stage
mode: restart
then:
- lambda: |-
auto call = id(fornuftig_fan).make_call();
if (id(fan_stage) == 0) {
call.set_state(false);
} else {
call.set_state(true);
call.set_speed(id(fan_stage));
}
call.perform();
binary_sensor:
- platform: gpio
id: "knob_1"
pin:
number: D7
mode: INPUT_PULLUP
inverted: true
filters:
- delayed_on_off: 50ms
on_press:
then:
- lambda: |-
if (id(fan_stage) > 0) {
id(fan_stage) = 0;
} else {
id(fan_stage) = 1;
}
id(apply_fan_stage).execute();
light:
- platform: binary
id: "status_led"
name: "status LED"
output: light_output
output:
- id: light_output
platform: gpio
pin: D3
- platform: esp8266_pwm
pin: D1
frequency: 150 Hz
id: pwm_output
inverted: false
Home Assistant Dashboard Example
You can add this quick Tile layout to your Home Assistant dashboard for a seamless control panel:
type: grid
columns: 1
square: false
cards:
- type: tile
entity: fan.fornuftig_lufter
icon: mdi:air-purifier
name: FORNÜFTIG Purifier
features:
- type: fan-speed
- type: sensor
entity: sensor.fornuftig_fanspeed
name: Fan Speed (RPM)
graph: line
Again with help of AI, generated an adapted ESPHOME yaml of my v1 solution for the PCB v2 version, replacing the micopython code.
disclaimer: this has not been tested; just posting for convenience!
# ==============================================================================
# IKEA FORNÜFTIG / uPurifier - 5-Stage Native ESPHome Replacement (v2 PCB Aligned)
# Replaces the deprecated MicroPython MQTT firmware with Native HA API control.
# ==============================================================================
substitutions:
devicename: upurifier-fornuftig
friendly_name: "uPurifier FÖRNÜFTIG"
update_interval: 30s
wifissid: "YOUR_WIFI_SSID"
wifipassword: "YOUR_WIFI_PASSWORD"
esphome:
name: $devicename
esp8266:
board: d1_mini
wifi:
networks:
- ssid: ${wifissid}
password: ${wifipassword}
ap:
ssid: "${devicename}-fallback"
password: "CHANGE_THIS_PASSWORD"
web_server:
port: 80
logger:
level: DEBUG
logs:
adc: INFO
gpio: INFO
# Native Home Assistant API replaces the old MQTT Auto-Discovery
api:
ota:
- platform: esphome
password: "CHOOSE_A_SECURE_OTA_PASSWORD"
captive_portal:
globals:
- id: fan_stage
type: int
restore_value: yes
initial_value: "0"
- id: last_encoder_value
type: int
restore_value: no
initial_value: "0"
switch:
- platform: restart
name: "${friendly_name} Restart"
fan:
- platform: template
name: "${friendly_name} Lüfter"
id: fornuftig_fan
speed_count: 5
on_speed_set:
- lambda: |-
auto p_out = id(pwm_output);
int current_speed = id(fornuftig_fan).speed;
if (current_speed == 1) { p_out->set_frequency(120.0f); p_out->set_level(0.50f); }
if (current_speed == 2) { p_out->set_frequency(165.0f); p_out->set_level(0.50f); }
if (current_speed == 3) { p_out->set_frequency(210.0f); p_out->set_level(0.50f); }
if (current_speed == 4) { p_out->set_frequency(255.0f); p_out->set_level(0.50f); }
if (current_speed == 5) { p_out->set_frequency(300.0f); p_out->set_level(0.50f); }
on_turn_on:
- lambda: |-
if (id(fan_stage) == 0) id(fan_stage) = 1;
id(fornuftig_fan).speed = id(fan_stage);
auto p_out = id(pwm_output);
auto led_call = id(status_led).turn_on();
led_call.perform();
if (id(fan_stage) == 1) { p_out->set_frequency(120.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 2) { p_out->set_frequency(165.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 3) { p_out->set_frequency(210.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 4) { p_out->set_frequency(245.0f); p_out->set_level(0.50f); }
if (id(fan_stage) == 5) { p_out->set_frequency(300.0f); p_out->set_level(0.50f); }
on_turn_off:
- lambda: |-
id(fan_stage) = 0;
auto led_call = id(status_led).turn_off();
led_call.perform();
id(pwm_output).set_level(0.00f);
sensor:
- platform: template
name: "$friendly_name - Power consumption"
id: fonuftig_power_consumption
device_class: power
state_class: measurement
unit_of_measurement: "W"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
return (id(fan_stage) * 0.10) + 0.01;
- platform: integration
name: "$friendly_name - Consumed Energy"
sensor: fonuftig_power_consumption
time_unit: "h"
device_class: energy
state_class: total_increasing
unit_of_measurement: "Wh"
restore: false
integration_method: left
- platform: pulse_counter
pin:
number: D2
mode: INPUT_PULLUP
unit_of_measurement: "RPM"
accuracy_decimals: 0
id: fan_tach
name: "Fornuftig Fanspeed"
update_interval: 3s
filters:
- multiply: 0.1
- platform: rotary_encoder
id: my_rotary_encoder
name: "Rotary Encoder"
min_value: -10000
max_value: 10000
filters:
- debounce: 50ms
pin_a:
number: D5
inverted: true
mode:
input: true
pullup: true
pin_b:
number: D6
inverted: true
mode:
input: true
pullup: true
on_value:
then:
- lambda: |-
int current_val = (int)x;
if (current_val != id(last_encoder_value)) {
if (current_val < id(last_encoder_value)) {
if (id(fan_stage) > 1) id(fan_stage) -= 1;
}
else {
if (id(fan_stage) < 5) id(fan_stage) += 1;
}
id(last_encoder_value) = current_val;
if (id(fan_stage) > 0) {
id(apply_fan_stage).execute();
}
}
script:
- id: apply_fan_stage
mode: restart
then:
- lambda: |-
auto call = id(fornuftig_fan).make_call();
if (id(fan_stage) == 0) {
call.set_state(false);
} else {
call.set_state(true);
call.set_speed(id(fan_stage));
}
call.perform();
binary_sensor:
- platform: gpio
id: "knob_1"
pin:
number: D7
mode: INPUT_PULLUP
inverted: true
filters:
- delayed_on_off: 50ms
on_press:
then:
- lambda: |-
if (id(fan_stage) > 0) {
id(fan_stage) = 0;
} else {
id(fan_stage) = 1;
}
id(apply_fan_stage).execute();
light:
- platform: binary
id: "status_led"
name: "status LED"
output: light_output
output:
- id: light_output
platform: gpio
pin: D3
- platform: esp8266_pwm
pin: D1
frequency: 150 Hz
id: pwm_output
inverted: false






