ESPHome component for Xiaomi/Smartmi devices that speak the legacy miio UART
protocol instead of MIoT — the ones tagged miio2miot on
Xiaomi MIoT Spec, where the serial bus shows
messages like props power "on".
| Device | Model | Status | UART pins | Notes |
|---|---|---|---|---|
| Smartmi Standing Fan 2 | zhimi.fan.za3 |
✅ verified on hardware | TX GPIO17 / RX GPIO16 |
Full feature set. Wiring and teardown photos in dhewg/esphome-miot#85. |
Other miio2miot devices should work with the hub — the transport is the same
for all of them — but the command set differs per model and none have been
tested. If you get one running, a PR adding it to this table is welcome.
The WiFi module in these devices already is an ESP32 — it is not replaced, it gets flashed with ESPHome. The module talks to the fan's own MCU over UART, and that MCU is what speaks the protocol described here; it keeps running the Xiaomi firmware and handles the physical keys on its own.
Flashing needs a serial adapter soldered to the module's pads. Unless you dump the stock firmware first, this is a one-way trip — which is what makes a working component necessary rather than optional.
For the zhimi.fan.za3, the module pinout, soldering photos and the UART pins
are documented in the upstream issue:
The board config needs these options, because Xiaomi's ESP32 modules do not carry a valid eFuse MAC CRC:
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_FREERTOS_UNICORE: y
advanced:
ignore_efuse_custom_mac: true
ignore_efuse_mac_crc: truedhewg/esphome-miot is the component to
use for Xiaomi devices — but it speaks MIoT (set_properties <siid> <piid> <value>). Devices like the zhimi.fan.za3 answer every MIoT command with
error "method not found" -5000, which its README documents as unsupported.
Once the module has been flashed with ESPHome, going back to the stock firmware is not usually an option, so the remaining path is to speak the old protocol. That is what this component does.
The protocol was reverse engineered on the device; the full write-up is in docs/PROTOCOL.md.
The transport layer of this component is closely modelled on
miot.cpp
by @dhewg — the RX buffering in loop(), the
process_message_() dispatch, the network status handling and the get_down
command queue all follow its structure. This is a derivative work, not an
independent reimplementation, and it is published under the same license.
Everything above the transport is different: name-based properties instead of
siid/piid, legacy method names, the quoted-string argument format, the
props parser, physical button events and the fan platform.
MIoT (dhewg/esphome-miot) |
legacy miio (this) | |
|---|---|---|
| Addressing | numeric siid / piid |
property names as plain text |
| Write | set_properties 2 1 1 |
set_power "on" |
| Read | get_properties <siid> <piid> ... |
get_prop "name","name",... |
| Arguments | positional numbers | strings quoted, numbers bare |
Copy components/zhimi_miio/ next to your ESPHome YAML and reference it:
external_components:
- source:
type: local
path: componentsOr pull it straight from GitHub:
external_components:
- source: github://deviant-aut/esphome-zhimi-miio@mainThen see zhimi.fan.za3.yaml for a complete
configuration, and copy secrets.yaml.example to secrets.yaml.
Check your UART pins against the tested devices table and the wiring photos linked under Hardware. Also set
logger: baud_rate: 0— the MCU owns UART0 and logging to it corrupts the protocol.
uart:
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 115200
zhimi_miio:
id: fan_hub
poll_interval: 10s
poll_properties: [power, mode, speed_level, natural_level, angle]
on_button_press:
- lambda: 'ESP_LOGI("app", "physical key: %s", x.c_str());'Polling is what makes the state real. The MCU pushes only five properties on
its own; everything else — including the wind mode itself — has to be read back
with get_prop. Probing a new device is easy because unsupported property
names answer "null" instead of failing.
| Option | Description |
|---|---|
poll_properties |
Property names to read back with get_prop. Empty (default) disables polling. |
poll_interval |
How often to poll. Default 10s. |
on_button_press |
Fires when the MCU reports a physical key press. x is the key name, e.g. "speed" or "angle". |
ota_net_indicator |
Network state reported to the MCU during an OTA update. Default updating. |
Methods available in lambdas:
| Method | Description |
|---|---|
queue_command(cmd) |
Send a raw command, e.g. set_angle 120. |
set_string_prop(prop, value) |
set_<prop> "<value>" |
set_number_prop(prop, value) |
set_<prop> <value> |
set_mode(mode) |
Wind mode, "natural" or "normal". Keeps the cached value in sync. |
request_refresh() |
Poll all configured properties right now. |
has_prop(name) / get_string(name) / get_number(name) / get_bool(name) |
Read cached state. |
props_summary() |
All known properties, for a diagnostic text sensor. |
Use set_mode() rather than writing natural_level directly — it carries the
current level over, and the MCU never echoes the mode back, so the cached value
has to be updated locally.
fan:
- platform: zhimi_miio
name: "Fan"On/off, stepless speed 1–100 and oscillation. While natural wind is active the
speed is written to natural_level, otherwise to speed_level.
Everything else (natural wind, gear 1–4, oscillation angle, child lock, buzzer,
LED brightness, off delay, fan RPM, operating hours) is done with standard
template entities reading from the polled state — see the example YAML.
The MCU reports physical key presses, so multi-press detection can run entirely on the ESP. The example config toggles between natural and normal wind on a double press of the oscillation key, which works with Home Assistant offline or WiFi down.
A long press cannot be used: the MCU sends the same message for short and long presses. See docs/PROTOCOL.md.
Verified on zhimi.fan.za3 hardware (see Tested devices):
power, stepless speed, gear 1–4, natural wind, oscillation and angle, physical
key events, the double-press toggle, and reading every property back including
fan RPM and operating hours.
Known gaps:
- The AP fallback path is reasoned from the ESPHome sources, not measured — the test would have meant flashing a config with an unreachable SSID.
get_propwas tested with up to 14 properties in one request; where the actual limit is was not established.
ESPHome License, matching the upstream project this is derived from: GPLv3 for the C++ sources, MIT for the Python parts.