前言:

自從改過 IKEA 的空氣感測器後,好久沒發改造文了!這次是因為台灣這台剛好特賣出清,1299→799! 雖然結構只是濾網加風扇,但體積輕巧、不佔空間,而且又能拿來改造,所以就決定買來玩看看! 相關開箱文就不特別寫了,這篇會著重在改造部分!

改造說明:

結果買來本來以為很好拆,但實際上有兩個比較麻煩的地方:

第一個是要準備細長的三角形螺絲起子。因為螺絲孔洞很深,一般多用途可換頭的螺絲起子可能會因為頭太大而插不進洞口……而且螺絲多、又鎖得很緊,需要花點功夫。

20260626_193537.jpg

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

20260626_193036.jpg

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

20260626_192953.jpg

再來就是照下表跳線至ESP,我這次是用ESP32-C3

UPPÅTVIND 測試點 ESP32-C3 用途
TP2 GND 共地
TP3 5V 供電
TP4 GPIO10 按鈕(open-drain 輸出 + 輸入監測)
TP7 GPIO6 風速 LED1 亮度 (pulse_width,只用來校正中/高速)
TP6 GPIO7 濾芯更換紅色 LED (數位輸入)

焊接完成就能放回去,內部空間很大,要在ESP上加更多感測器也是沒問題的!

20260626_193337.jpg

最後參考這位作者的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

Screenshot_20260718_134154_Home Assistant.png