Local control of first-gen MySmartBlinds (tilt motors) with ESPHome — no cloud, no hub
TL;DR: The original MySmartBlinds “Smart Blind” tilt motors (they advertise as SmartBlind_DFU) can be driven entirely locally from Home Assistant using an ESP32 running ESPHome as a Bluetooth proxy, with two ble_client entries and template covers. No SwitchBot hub, no MySmartBlinds cloud. This matters now that the vendor cloud is winding down. There’s an existing Python library and a HACS integration (benbalter/smartblinds-ble and benbalter/ha-smartblinds-ble), but the HACS integration targets the newer roller shades — this post is the ESPHome-only recipe for the first-generation tilt motors, plus a workaround for an ESPHome ble_client.connect quirk that made commands silently fail.
What you need
- One or more first-gen MySmartBlinds tilt motors (model like
MSB-T001-...), already set up once in the MySmartBlinds app so they have pairing keys. - An ESP32 running ESPHome, within good BLE range of the blinds, acting as a Bluetooth proxy.
- Each blind’s per-blind BLE pairing key. These are secret — treat them like passwords. You extract your own keys from the app’s offline backup export; the smartblinds-ble project documents how. Do not share your keys or post them anywhere.
- Each blind’s BLE MAC address (the app backup / a BLE scanner shows these).
How the protocol works
For each motor you: connect, write the pairing key to characteristic 0x1409, then write a single position byte to characteristic 0x1403 (both under service 0x1400). Positions: 100 = flat/open, 200 = closed. The motors are open-loop — they never report their position back — so in HA these are optimistic covers: HA shows what you last commanded, not what the slats are actually doing. A motor also accepts only one BLE connection at a time, so if the phone app is open in the same room it will steal the connection and your HA commands will fail until you close it.
The ESPHome config (sanitized — put in your real MACs and keys)
esphome:
name: living-room-blinds
# The motors use RANDOM static BLE addresses. With auto_connect: false the
# ble_client never learns the address type and dials them as PUBLIC (log:
# "0x00 Connecting" -> status 133 timeout). Set it once at boot.
on_boot:
priority: -100
then:
- lambda: |-
id(left_blind).set_remote_addr_type(BLE_ADDR_TYPE_RANDOM);
id(right_blind).set_remote_addr_type(BLE_ADDR_TYPE_RANDOM);
esp32:
variant: esp32
framework:
type: esp-idf
logger:
api:
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
esp32_ble_tracker:
scan_parameters:
active: false
bluetooth_proxy:
active: true
# One slot per blind you connect + headroom. The ESP32 has 3 total; if you
# also use this board as a proxy for other things, budget accordingly.
connection_slots: 1
globals:
# Target position byte the on_connect handler will write. 100 = open, 200 = closed.
- id: left_target
type: uint8_t
initial_value: '100'
- id: right_target
type: uint8_t
initial_value: '100'
# Set true by on_connect once the position byte is written; the send scripts
# poll it to know whether the command actually landed.
- id: left_done
type: bool
initial_value: 'false'
- id: right_done
type: bool
initial_value: 'false'
ble_client:
- id: left_blind
mac_address: AA:BB:CC:DD:EE:01 # <-- your Left blind's MAC
auto_connect: false
on_connect:
then:
- delay: 200ms # motor needs a beat before it accepts writes
- ble_client.ble_write:
id: left_blind
service_uuid: 00001400-1212-efde-1600-785feabcd123
characteristic_uuid: 00001409-1212-efde-1600-785feabcd123
value: [0x00, 0x00, 0x00, 0x00] # <-- your Left blind's KEY bytes (REDACTED)
- ble_client.ble_write:
id: left_blind
service_uuid: 00001400-1212-efde-1600-785feabcd123
characteristic_uuid: 00001403-1212-efde-1600-785feabcd123
value: !lambda 'return {id(left_target)};'
- delay: 500ms
- lambda: 'id(left_done) = true;'
- ble_client.disconnect: left_blind
- id: right_blind
mac_address: AA:BB:CC:DD:EE:02 # <-- your Right blind's MAC
auto_connect: false
on_connect:
then:
- delay: 200ms
- ble_client.ble_write:
id: right_blind
service_uuid: 00001400-1212-efde-1600-785feabcd123
characteristic_uuid: 00001409-1212-efde-1600-785feabcd123
value: [0x00, 0x00, 0x00, 0x00] # <-- your Right blind's KEY bytes (REDACTED)
- ble_client.ble_write:
id: right_blind
service_uuid: 00001400-1212-efde-1600-785feabcd123
characteristic_uuid: 00001403-1212-efde-1600-785feabcd123
value: !lambda 'return {id(right_target)};'
- delay: 500ms
- lambda: 'id(right_done) = true;'
- ble_client.disconnect: right_blind
# --- The important workaround: see "The ble_client.connect gotcha" below ---
script:
- id: left_send
mode: restart
then:
- lambda: 'id(left_done) = false;'
- repeat:
count: 3
then:
- if:
condition:
lambda: 'return !id(left_done);'
then:
- lambda: |-
auto st = id(left_blind).state();
if (st != esp32_ble_tracker::ClientState::IDLE) {
ESP_LOGW("blinds", "left: client not IDLE (%d) - forcing reset", (int) st);
id(left_blind).unconditional_disconnect();
id(left_blind).set_state(esp32_ble_tracker::ClientState::IDLE);
}
- delay: 300ms
- lambda: 'id(left_blind).connect();'
- wait_until:
condition:
lambda: 'return id(left_done) || id(left_blind).state() == esp32_ble_tracker::ClientState::IDLE;'
timeout: 12s
- if:
condition:
lambda: 'return !id(left_done);'
then:
- delay: 1s
- if:
condition:
lambda: 'return !id(left_done);'
then:
- lambda: 'ESP_LOGE("blinds", "left: gave up after 3 attempts");'
- id: right_send
mode: restart
then:
- lambda: 'id(right_done) = false;'
- repeat:
count: 3
then:
- if:
condition:
lambda: 'return !id(right_done);'
then:
- lambda: |-
auto st = id(right_blind).state();
if (st != esp32_ble_tracker::ClientState::IDLE) {
ESP_LOGW("blinds", "right: client not IDLE (%d) - forcing reset", (int) st);
id(right_blind).unconditional_disconnect();
id(right_blind).set_state(esp32_ble_tracker::ClientState::IDLE);
}
- delay: 300ms
- lambda: 'id(right_blind).connect();'
- wait_until:
condition:
lambda: 'return id(right_done) || id(right_blind).state() == esp32_ble_tracker::ClientState::IDLE;'
timeout: 12s
- if:
condition:
lambda: 'return !id(right_done);'
then:
- delay: 1s
- if:
condition:
lambda: 'return !id(right_done);'
then:
- lambda: 'ESP_LOGE("blinds", "right: gave up after 3 attempts");'
cover:
- platform: template
name: "Left Blind"
id: left_blind_cover
device_class: blind
assumed_state: true
optimistic: true
has_position: false
open_action:
- globals.set: { id: left_target, value: '100' }
- cover.template.publish: { id: left_blind_cover, tilt: 1.0 }
- script.execute: left_send
close_action:
- globals.set: { id: left_target, value: '200' }
- cover.template.publish: { id: left_blind_cover, tilt: 0.0 }
- script.execute: left_send
tilt_action:
- globals.set:
id: left_target
value: !lambda 'return (uint8_t)(200 - (int)(tilt * 100));'
- cover.template.publish:
id: left_blind_cover
state: !lambda 'return tilt > 0.0 ? COVER_OPEN : COVER_CLOSED;'
- script.execute: left_send
- platform: template
name: "Right Blind"
id: right_blind_cover
device_class: blind
assumed_state: true
optimistic: true
has_position: false
open_action:
- globals.set: { id: right_target, value: '100' }
- cover.template.publish: { id: right_blind_cover, tilt: 1.0 }
- script.execute: right_send
close_action:
- globals.set: { id: right_target, value: '200' }
- cover.template.publish: { id: right_blind_cover, tilt: 0.0 }
- script.execute: right_send
tilt_action:
- globals.set:
id: right_target
value: !lambda 'return (uint8_t)(200 - (int)(tilt * 100));'
- cover.template.publish:
id: right_blind_cover
state: !lambda 'return tilt > 0.0 ? COVER_OPEN : COVER_CLOSED;'
- script.execute: right_send
The ble_client.connect gotcha (the part that cost me a day)
If you wire the covers to the built-in ble_client.connect action directly, you’ll find that after the first failed/timed-out connection (motor briefly busy, out of range, whatever), every subsequent command is silently dropped — the log shows your action fire but no “Connecting” line ever appears.
Reading the ESPHome source (ble_client/automation.h), the BLEClientConnectAction’s play_complex() starts with if (num_running_ != 0) { stop_complex(); return; }, and num_running_ is only cleared on ESP_GATTC_SEARCH_CMPL_EVT (success) or ESP_GATTC_DISCONNECT_EVT. A failed open sets the client back to IDLE but never fires DISCONNECT_EVT, so the counter stays stuck at 1 and the action no-ops forever after. (Related open report: esphome/issues #6443.)
The workaround above sidesteps the action entirely: a script (mode restart) forces the client back to IDLE if it isn’t already, calls id(x_blind).connect() directly in a lambda, waits for a “done” flag or a return to IDLE, and retries up to 3 times. Since I switched to this, commands land every time.
Notes / caveats
- Open-loop: HA state is optimistic. If you move a blind by its wand, HA won’t know.
- One connection at a time: close the phone app when testing, or it’ll hold the connection.
- Onboard schedule lives on: these motors keep running whatever sun-tracking schedule you set in the app, even with no cloud. You can’t edit that schedule without the app/cloud, but HA can send an open/close earlier and “beat” the motor’s own schedule (the motor’s later move is then a no-op).
Hope this saves someone else from re-deriving it. Happy to answer questions.
Links: smartblinds-ble (Python) · ha-smartblinds-ble (HACS, roller shades) · ESPHome BLE Client docs · ESPHome issue #6443