IKEA FÖRNUFTIG in Home-Assistant

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! :slight_smile:

[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 :slight_smile: [/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

  1. ADC Reading: Now checks the filter LED status at the specified sensor_update_interval instead of every 100 ms—no need to hammer the ADC for an LED that stays lit for hours.
  2. Energy Sensor Filters: Added filtering to the total_daily_energy sensor for smoother readings.
  3. 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. :slight_smile:

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 :wink:
Here is a revised ESPHOME YAML for my build with the custom PCB v1.
Also changed the speed to 5 stages.


:leaf_fluttering_in_wind: 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.

:rocket: 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.

:pushpin: 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

:hammer_and_wrench: Flashing & Deployment

  1. Make sure you have ESPHome installed (either as a Home Assistant Add-on or via CLI).
  2. Download or copy the provided esp82-fornuftig-hack.yaml configuration.
  3. Replace the fallback credentials (YOUR_WIFI_SSID, YOUR_WIFI_PASSWORD, and USE_YOUR_OWN_SECURE_OTA_PASSWORD) with your actual network credentials.
  4. Connect your Wemos D1 Mini via USB and run:
    esphome run esp82-fornuftig-hack.yaml
    
  5. Once flashed successfully, the device will automatically appear in Home Assistant as a native Fan entity.

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

:bar_chart: 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