sourcediver.org

2024/02/18

Unclouding Xiaomi Air Purifiers

Maximilian Güntner

Motivation and Background

I recently bought an used Xiaomi Air Purifier (Model 3C) which is, like most modern devices with a cable, “smart”. Unfortunately manufacturers and their customers associate this with having a proprietary app that communicates with a proprietary cloud. While an air purifier is not the most valuable thing to have in any home automation you should at least setup such a device is that it has an unprotected AP open over which the initial configuration can be done. If you do don’t do that, your neighbor might claim your device and start controlling it. And of course it is always nice to be able to control things remotely 😉.

After resetting the device several times, obtaining an auth token that you need to control the device locally and having it connect to my dedicated home automation VLAN/Wifi, I was never able to connect to it. It always attempted to connect to the Xiaomi Cloud using various way (UDP, TCP, HTTP OTA) but requests using the token obtained previously were met with silence GitHub Issue with people reporting similar issues with different devices. This was not always the case as there are reports of people being able to setup their device in Home Assistant without a Xiaomi account / the device phoning home. It seems like that the token is refreshed after the intial configuration / after joining the “customer’s wifi”.

I almost gave up on the idea of getting the device into my Home Assistant installation but then I found several repositories that are replacing the Xiaomi firmware of the ESP32 within the device with ESPHome. The device itself contains two microcontrollers, a STM32 that controls the device (motor, sensors) and an ESP32 that is the gateway to the Xiaomi cloud / the local network. Both microcontrollers communicate using the Miot serial protocol where the STM32 reports changed properties such as air quality or temperature to the ESP32 which in turn sends commands such as set rotor speed to maximum. This makes it perfectly safe and easy to replace the ESP32 fireware was the functional aspects of the machine remain untouched.

What follows it the documentation how to free a Xiaomi Air Purifier 3C from its proprietary firmware.

Documentation

Disclaimer: You are modifying the device at your own risk.

a Xiaomi Air Purifier 3C

a Xiaomi Air Purifier 3C

Opening up

Remove the filter, turn the top upside down and loosen all the screws. Carefully remove the cover, unplug the JST connector for the safety switch. You will be met by this view.

inside view of the purifier

inside view of the Xiaomi Air Purifier 3C

Connect the programmer

pinout

pinout at the ESP32

Use a 4x1 pin header, connect 3.3V, RX, TX and GND of a UART-USB programmer according to the picture above, then plug the pin header into the holes, no need for soldering!

Connect the programmer to your workstation. Run an application to read the serial output, like minicom -D /dev/ttyUSB0. You should see some output of the firmware refusing to boot.

rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0018,len:4
load:0x3fff001c,len:6316
load:0x40078000,len:9584
load:0x40080000,len:6272
entry 0x4008038c
I (29) boot: ESP-IDF cef6c09-dirty 2nd stage bootloader
I (29) boot: compile time 19:14:10
I (29) boot: Enabling RNG early entropy source...
I (34) boot: SPI Flash RID  : 0x1C7016
I (38) boot: SPI Flash  MF  : 0x1C
I (43) boot: SPI Flash  ID  : 0x7016
I (47) boot: SPI Speed      : 40MHz
I (51) boot: SPI Mode       : DIO
I (55) boot: SPI Flash Size : 4MB
I (59) boot: Partition Table:
I (63) boot: ## Label            Usage          Type ST Offset   Length
I (70) boot:  0 nvs              WiFi data        01 02 00009000 00004000
I (77) boot:  1 otadata          OTA data         01 00 0000d000 00002000
I (85) boot:  2 phy_init         RF data          01 01 0000f000 00001000
I (92) boot:  3 miio_fw1         OTA app          00 10 00010000 00160000
I (100) boot:  4 miio_fw2         OTA app          00 11 00170000 00160000
I (107) boot:  5 test             test app         00 20 002d0000 00013000
I (115) boot:  6 mimcu            Unknown data     01 fd 002e3000 00100000
I (122) boot:  7 coredump         Unknown data     01 03 003e3000 00010000
I (130) boot:  8 minvs            Unknown data     01 fe 003f8000 00004000
I (138) boot: End of partition table
I (142) boot: No factory image, trying OTA 0
I (147) esp_image: segment 0: paddr=0x00010020 vaddr=0x3f400020 size=0x29818 (170008) map
I (215) esp_image: segment 1: paddr=0x00039840 vaddr=0x3ffc0000 size=0x033a8 ( 13224) load
I (221) esp_image: segment 2: paddr=0x0003cbf0 vaddr=0x3ffc33a8 size=0x003c0 (   960) load
I (223) esp_image: segment 3: paddr=0x0003cfb8 vaddr=0x40080000 size=0x00400 (  1024) load
I (232) esp_image: segment 4: paddr=0x0003d3c0 vaddr=0x40080400 size=0x02c50 ( 11344) load
I (245) esp_image: segment 5: paddr=0x00040018 vaddr=0x400d0018 size=0xe8a04 (952836) map
I (583) esp_image: segment 6: paddr=0x00128a24 vaddr=0x40083050 size=0x144c0 ( 83136) load
I (632) boot: Loaded app from partition at offset 0x10000
I (632) boot: Disabling RNG early entropy source...
I (632) cpu_start: Pro cpu up.
I (636) cpu_start: Single core mode
I (641) heap_init: Initializing. RAM available for dynamic allocation:
I (647) heap_init: At 3FFAFF10 len 000000F0 (0 KiB): DRAM
I (653) heap_init: At 3FFD1960 len 0000E6A0 (57 KiB): DRAM
I (659) heap_init: At 3FFE0440 len 00003BC0 (14 KiB): D/IRAM
I (666) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (672) heap_init: At 40097510 len 00008AF0 (34 KiB): IRAM
I (678) cpu_start: Pro cpu start user code
I (26) cpu_start: Starting scheduler on PRO CPU.
08:00:00.020 [D] Addon: miIO
08:00:00.020 [D]        : 0x400e20b8:   bind_stat
08:00:00.020 [D]        : 0x400e406c:   bleGetNearbyBandList
08:00:00.020 [D]        : 0x400e3fbc:   bleStartSearchBand
08:00:00.030 [D]        : 0x400e4098:   ble_gateway_enable
08:00:00.030 [D]        : 0x400e1998:   config_router
08:00:00.040 [D]        : 0x400e1a8c:   config_router_safe
08:00:00.040 [D]        : 0x400e1f74:   disable_local_restore
08:00:00.050 [D]        : 0x400d9598:   dyna_params
08:00:00.050 [D]        : 0x400e2004:   get_disable_local_restore
08:00:00.060 [D]        : 0x400da8ac:   get_ota_progress
08:00:00.070 [D]        : 0x400da548:   get_ota_state
08:00:00.070 [D]        : 0x400db8f0:   handshake
08:00:00.080 [D]        : 0x400d9d80:   help
08:00:00.080 [D]        : 0x400d7628:   info
08:00:00.080 [D]        : 0x400d6b8c:   iperf_client
08:00:00.090 [D]        : 0x400e20d0:   migration
08:00:00.090 [D]        : 0x400d94ac:   ncinfo
08:00:00.100 [D]        : 0x400da23c:   ota
08:00:00.100 [D]        : 0x400da9c0:   ota_install
08:00:00.110 [D]        : 0x400da09c:   reboot
08:00:00.110 [D]        : 0x400d9950:   redirect
08:00:00.120 [D]        : 0x400e1f10:   restore
08:00:00.120 [D] Addon: user
08:00:00.130 [D]        : 0x400e20a0:   bind_key
08:00:00.130 [D]        : 0x400d521c:   hello
08:00:00.130 [D] Addon: init complete
08:00:00.140 [D] httpc: idle
08:00:00.140 [D] arch_os: create handle = 3ffd9964, name = httpc_task, prio = 10
08:00:00.160 [D] otu: otu is idle...
08:00:00.160 [D] arch_os: create handle = 3ffdabc4, name = otu_task, prio = 10
08:00:00.160 [D] ots: ots is idle...
08:00:00.170 [D] arch_os: create handle = 3ffdbe24, name = ots_task, prio = 10
08:00:00.180 [I] miio_ot: httpdns enabled
08:00:00.190 [I] miio_ot: dlg enabled
08:00:00.190 [D] ots: event on[id=1]
08:00:00.190 [D] ots: event on[id=5]
08:00:00.190 [D] ots: event on[id=9]
08:00:00.200 [D] ots: event on[id=10]
08:00:00.200 [D] ot: event on[id=1]
08:00:00.210 [D] ot: event on[id=2]
08:00:00.210 [D] ot: event on[id=3]
08:00:00.210 [D] ot: event on[id=4]
08:00:00.220 [D] otu: event on[id=2]
08:00:00.220 [D] otu: event on[id=3]
08:00:00.230 [D] otu: event on[id=4]
08:00:00.230 [D] otu: event on[id=5]
08:00:00.240 [D] otu: event on[id=6]
08:00:00.240 [D] otu: event on[id=7]
08:00:00.240 [D] otu: event on[id=8]
08:00:00.250 [D] otu: event on[id=9]
08:00:00.250 [D] otu: event on[id=10]
08:00:00.260 [D] otu: event on[id=11]
08:00:00.260 [D] otu: event on[id=12]
08:00:00.270 [D] otu: event on[id=14]
08:00:00.270 [D] ots: event on[id=2]
08:00:00.280 [D] ots: event on[id=3]
08:00:00.280 [D] ots: event on[id=4]
08:00:00.280 [D] ots: event on[id=5]
08:00:00.290 [D] ots: event on[id=6]
08:00:00.290 [D] ots: event on[id=7]                                                                                  
08:00:00.300 [D] ots: event on[id=8]                                                                                  
08:00:00.300 [D] ots: event on[id=9]                                                                                  
08:00:00.310 [D] ots: event on[id=10]                                                                                 
08:00:00.310 [D] ots: event on[id=11]
08:00:00.310 [D] ots: event on[id=12]
08:00:00.320 [D] arch_os: create handle = 3ffdd9f8, name = mi_otn, prio = 10
I (709) BTDM_INIT: BT controller compile version [382a548]

Getting into the bootloader

While still checking the output, connect GPIO0 and GND using a wire. Again, no need to solder! Once you see that the bootloader has been loaded, you can put the wire aside.

Dump original firmware

Just to be sure, dump your current firmware.

$ esptool.py read_flash 0x00000 0x400000 purifier_flash_4M.bin

Build ESPHome

Create a new folder or reuse your esphome project folder, ⭐ and clone this repository, create a new file named secrets.yaml or extend your existing one with the following content.

wifi_ssid: YourWifiSSID
wifi_password: YourWifiPSK
wifi_ap_password: WifiPSKForBackupAP
api_encryption_key: d0H+GHC9Jau84wWmEfegFnXBfZuK3eqvtsPETSmAaq8= # CHANGEME
ota_password: otapassword123

secrets.yaml

Link the base config to the main folder:

$ ln -s esphome-miot/config/zhimi.airp.mb4a.yaml . 

Create a file for the new device:

---
substitutions:
  name: purifier-3c-1
  name_upper: Purifier 3C

<<: !include zhimi.airp.mb4a.yaml

my_purifier.yaml

Build the firmware

$ esphome compile my_purifier.yaml

Make sure that your esphome version is up-to-date or the firmware will not compile. I used 2023.12.9.

Flashing

Once your are done, make sure that the ESP32 is still in bootloader mode. You can use esphome run but I did it manually using esptool.py.

For that, run withing .esphome/build/my_purifier/.pioenvs/my_purifier

cd .esphome/build/my_purifier/.pioenvs/my_purifier
esptool.py write_flash 0x8000 partitions.bin
esptool.py write_flash 0x1000 bootloader.bin
esptool.py write_flash 0x10000 firmware.bin
flashing process

Buckle your seatbelt Dorothy, 'cause Kansas is going bye-bye!

Unplug the programmer / 3.3V to reset the ESP32, using minicom you should now be able to observe ESPHome booting. If you want, you can short the two pins on the JST port you disconnected previously and plug the unit into the mains to test everything before putting the device together again.

After testing, disconnect the device from the mains, disconnect the programmer. Then do the steps in reverse that were required to unbuild it. Do not forget the JST connector!

ESPHome and HomeAssistant

You should give the device a static DHCP lease in your router. Afterwards add it in Home Assistant.

Home Assistant UI

the liberated air purifier in Home Assistant

You can now control the Xiaomi Air Purifier 3C using Home Assistant and ESPHome!

Credits

Thanks to @dhewg et al. for the implementation!