自從改過 IKEA 的空氣感測器後,好久沒發改造文了!這次是因為台灣這台剛好特賣出清,1299→799! 雖然結構只是濾網加風扇,但體積輕巧、不佔空間,而且又能拿來改造,所以就決定買來玩看看! 相關開箱文就不特別寫了,這篇會著重在改造部分!
結果買來本來以為很好拆,但實際上有兩個比較麻煩的地方:
第一個是要準備細長的三角形螺絲起子。因為螺絲孔洞很深,一般多用途可換頭的螺絲起子可能會因為頭太大而插不進洞口……而且螺絲多、又鎖得很緊,需要花點功夫。

第二個麻煩點是有卡扣;用起子撬起來很容易把卡榫弄斷。不過還能用螺絲固定,所以就算斷了也沒差…

拆開後就能看到主機板,一如往常的有很多TP點可以拿來焊接用

再來就是照下表跳線至ESP,我這次是用ESP32-C3
| UPPÅTVIND 測試點 | ESP32-C3 | 用途 |
|---|---|---|
| TP2 | GND | 共地 |
| TP3 | 5V | 供電 |
| TP4 | GPIO10 | 按鈕(open-drain 輸出 + 輸入監測) |
| TP7 | GPIO6 | 風速 LED1 亮度 (pulse_width,只用來校正中/高速) |
| TP6 | GPIO7 | 濾芯更換紅色 LED (數位輸入) |
焊接完成就能放回去,內部空間很大,要在ESP上加更多感測器也是沒問題的!

最後參考這位作者的repo再搭配AI產生了以下的ESPhome YAML,加入HA後可以得到以下實體:
# =============================================================================
# IKEA UPPÅTVIND → ESPHome 智慧空氣清淨機 (ESP32-C3)
# 參考: <https://github.com/jonathonlui/esphome-ikea-uppatvind>
# -----------------------------------------------------------------------------
# 接線 (UPPÅTVIND 測試點 → ESP32-C3) ※ TP6/TP7 實際對調接
# TP2 → GND 共地
# TP3 → 5V 供電
# TP4 → GPIO10 按鈕(open-drain 輸出 + 輸入監測,INPUT_OUTPUT_OD)
# TP7 → GPIO6 風速 LED1 亮度 (pulse_width,只用來校正中/高速)
# TP6 → GPIO7 濾芯更換紅色 LED (數位輸入)
#
# 狀態追蹤(關鍵):此機 pulse_width 關機後會「卡住上一個值」,關和低 LED 都~20µs 分不出。
# 故改用「直接監測 TP4 按壓」追蹤段數:
# - 開機 = 關(0);實體只有單一按鈕,循環 關→低→中→高→關
# - 監測 TP4(GPIO10):短按(<1.5s)→進一格;長按(≥1.5s)→關機。我自己按與實體按都算。
# - LED 只在讀到「明確的中(100)/高(320)µs」時校正段數(漏按/失步時補正)。
# 風速切換:升速→短按往上(閉迴路);降速/關機→先 Hold 2.2s 關機再短按往上。
# =============================================================================
substitutions:
devicename: ikea-air-purifier
friendly_name: "IKEA Air Purifier"
TP4_PIN: GPIO10
TP7_PIN: GPIO6
TP6_PIN: GPIO7
esphome:
name: $devicename
friendly_name: ${friendly_name}
name_add_mac_suffix: false
comment: "IKEA UPPÅTVIND smart air purifier (ESP32-C3)"
esp32:
board: esp32-c3-devkitm-1
variant: ESP32C3
framework:
type: esp-idf
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: ${devicename}
password: "12345678"
captive_portal:
logger:
api:
ota:
- platform: esphome
#web_server:
# =============================================================================
# 全域變數
# =============================================================================
globals:
- id: current_level # 追蹤的目前風速 0..3(開機=0)
type: int
restore_value: false
initial_value: '0'
- id: target_speed_level # 想要到達的風速 0..3
type: int
restore_value: false
initial_value: '0'
- id: press_start_ms # 按下時間戳,用來分短按/長按
type: uint32_t
restore_value: false
initial_value: '0'
- id: press_guard # 閉迴路安全計數
type: int
restore_value: false
initial_value: '0'
# =============================================================================
# 輸出:TP4 / GPIO10 按鈕 (INPUT_OUTPUT_OD:可驅動也可回讀)
# =============================================================================
output:
- platform: gpio
id: button_output
pin:
number: $TP4_PIN
allow_other_uses: true
inverted: true
mode:
output: true
open_drain: true
input: true # 同腳位開輸入緩衝,供下方 monitor 讀回實體按壓
# fan 的虛擬輸出:把目標風速交給控制腳本
- platform: template
id: fan_speed_output
type: float
write_action:
- lambda: 'id(target_speed_level) = (int) lroundf(state * 3.0f);' # 0.0..1.0 → 0..3
- script.execute: set_fan_speed
# =============================================================================
# 顯示同步腳本:把 fan 實體段數對齊 current_level
# =============================================================================
script:
- id: publish_display
then:
- lambda: |-
int level = id(current_level);
auto *f = id(purifier_fan);
f->state = (level > 0);
if (level > 0) f->speed = level;
f->publish_state();
# 控制腳本:升速短按、降速/關機先 Hold 再往上(段數由 TP4 monitor 追蹤)
- id: set_fan_speed
mode: restart
then:
- globals.set:
id: press_guard
value: '0'
# 1) 降速或關機(目標<目前) → 先 Hold 2.2s 直接關機
- if:
condition:
lambda: 'return id(target_speed_level) < id(current_level);'
then:
- output.turn_on: button_output
- delay: 2200ms
- output.turn_off: button_output
- delay: 500ms
- lambda: 'id(current_level) = 0;' # 腳本自行追蹤(已關)
# 2) 短按往上到目標(腳本確定地每按一次 +1,不靠 monitor 以免 race)
- while:
condition:
lambda: 'return id(current_level) != id(target_speed_level) && id(press_guard) < 8;'
then:
- output.turn_on: button_output
- delay: 150ms
- output.turn_off: button_output
- delay: 600ms # 等裝置登記這次短按
- lambda: 'id(current_level) = (id(current_level) + 1) % 4; id(press_guard) += 1;'
- script.execute: publish_display
# =============================================================================
# 風扇實體 (空氣清淨機)
# =============================================================================
fan:
- platform: speed
id: purifier_fan
output: fan_speed_output
name: "${friendly_name}"
icon: mdi:air-purifier
speed_count: 3
restore_mode: NO_RESTORE
# =============================================================================
# 感測器
# =============================================================================
sensor:
# --- TP7(風速 LED) → GPIO6:只用來校正中/高速 ---
- platform: pulse_width
id: tp7_pulse
name: "${friendly_name} LED Pulse"
pin: $TP7_PIN
unit_of_measurement: "µs"
accuracy_decimals: 0
entity_category: diagnostic
update_interval: 0.25s
filters:
- multiply: 1000000
- median:
window_size: 3
send_every: 1
send_first_at: 1
# 註:此機 pulse_width 關機後會「卡住上一個值」,無法判狀態,故僅作診斷顯示,
# 不參與段數判定(段數全靠 TP4 按鈕監測 + 腳本追蹤)。
- platform: template
name: "${friendly_name} Speed Level"
entity_category: diagnostic
accuracy_decimals: 0
update_interval: 1s
lambda: 'return id(current_level);'
- platform: wifi_signal
name: "${friendly_name} WiFi Signal"
update_interval: 60s
entity_category: diagnostic
- platform: uptime
name: "${friendly_name} Uptime"
entity_category: diagnostic
# =============================================================================
# 二元感測器
# =============================================================================
binary_sensor:
# --- TP4 / GPIO10 按壓監測:追蹤段數的核心 ---
- platform: gpio
id: tp4_monitor
name: "${friendly_name} Button monitor"
entity_category: diagnostic
pin:
number: $TP4_PIN
allow_other_uses: true
inverted: true # TP4 被拉低(按下) → true
mode:
output: true
open_drain: true
input: true
filters:
- delayed_on_off: 20ms # 去抖
on_press:
- lambda: 'id(press_start_ms) = millis();'
on_release:
# 只在控制腳本沒在跑時處理(代表這是「實體按鈕」按的,不是腳本自己按的)
- if:
condition:
lambda: 'return !id(set_fan_speed)->is_running();'
then:
- lambda: |-
uint32_t dur = millis() - id(press_start_ms);
int nl = id(current_level);
if (dur >= 1500) nl = 0; // 長按 → 關機
else if (dur >= 30) nl = (nl + 1) % 4; // 短按 → 進一格
id(current_level) = nl;
- script.execute: publish_display
# --- TP6(濾芯 LED) → GPIO7 濾芯更換通知 ---
- platform: gpio
id: filter_led
name: "${friendly_name} Filter LED"
entity_category: diagnostic
pin:
number: $TP6_PIN
mode:
input: true
filters:
- delayed_on_off: 100ms
- platform: template
name: "${friendly_name} Filter Replacement"
device_class: problem
icon: mdi:air-filter
lambda: 'return id(filter_led).state;'
filters:
- delayed_on: 30s
- delayed_off: 5s
# =============================================================================
# 其他
# =============================================================================
text_sensor:
- platform: wifi_info
ip_address:
name: "${friendly_name} IP"
entity_category: diagnostic
button:
# 手動短按(循環) / 長按關機 — monitor 會自動把段數記對
- platform: output
name: "${friendly_name} Button"
output: button_output
duration: 150ms
icon: mdi:gesture-tap-button
entity_category: config
- platform: output
name: "${friendly_name} Power Off (hold)"
output: button_output
duration: 2200ms
icon: mdi:fan-off
entity_category: config
- platform: restart
name: "${friendly_name} Restart"
entity_category: config
