Local control of first-gen MySmartBlinds (tilt motors) with ESPHome — no cloud

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

1 Like

So glad it was helpful. Feedback / PRs welcome.

Ben — thanks, and glad the guide’s useful. Since you’ve been inside these, one question I’m stuck on.

I’d like to read battery level from the first-gen tilt motors (MSB-T001, advertise as SmartBlind_DFU). The app shows per-blind battery and a solar-charging indicator, so it’s coming over BLE somehow, but I can’t find it:

  • After the key write, the only readable characteristics with data are 1401 (name), 1409 (key echo), 1402 and 1404 — and 1402/1404 are byte-identical on both motors, so they look like firmware constants, not measurements.
  • Subscribing to every notify characteristic only ever gets 1402 status pushes. Byte 4 is a flags byte (top bit set once the key is accepted), byte 3 is 0x19 on both motors. Nothing that moves with charge.

So my guess is the app sends a specific request and the reply comes back on 1402 (or somewhere I’m not listening). Do you know what the app writes to ask for battery on the first-gen tilt motors, or which characteristic the answer lands on? Even a pointer to where it lives in the newer shades’ protocol would help — I can go sniff with that as a starting point.

Next step on my side otherwise is an HCI snoop capture of the app on an Android tablet.

Just wanted to say thank you for putting this together! I followed your guide and now have all six of my first-gen MySmartBlinds running locally through ESPHome and Home Assistant. It worked great, and it’s especially nice knowing I’m no longer dependent on the increasingly unreliable cloud service.

One thing that would make this absolutely amazing would be if there were eventually a way to poll the motors for their current state/tilt position and remaining battery level. Being able to keep the Home Assistant cover state synchronized even when a blind moves outside of HA, plus having battery sensors for each blind, would pretty much make this a complete replacement for the original app.

Thanks again for taking the time to figure this out and document it so clearly.

I struggled with GPT SOL 6 on the matter, as I am working on my options to reintegrate a cloudless mySmartBlinds back into my ecosystem. “We” were able to get the position, battery and solar readings working in a test python script. Here is the update I asked it to create for this thread to explain what was discovered:

I captured the official Android app’s BLE traffic with my first-generation SmartBlind_DFU motor and reproduced the results from a PC. The battery data is on characteristic 00001651-1212-efde-1600-785feabcd123. After writing the pairing key to 0x1409, subscribe to indications on 0x1651; reading 0x1402 and 0x1404 alone won’t reveal it.

The 0x1651 indication is 14 bytes. The app decodes byte 0 as battery percentage, byte 1 as reserved, then six little-endian 16-bit values: battery voltage, battery charge, solar-panel voltage, interior temperature, battery temperature, and raw light value. One packet from my motor reported 100% battery and a raw light value of 875. I haven’t verified the physical units of the other readings or calibrated the light value to lux.

Solar charging comes from the 0x1402 status indication: interpret its first four bytes as a little-endian bitfield. Bit 0x00040000 means solar charging; 0x00080000 means USB charging.

My motor also indicated 0x63 (99 on the 0–200 scale) on 0x1403. I haven’t yet tested whether that readback tracks movements made with the wand or onboard schedules, so I wouldn’t remove optimistic state handling on that evidence alone.

None of these findings require sharing the motor’s pairing key.

Let me know if you have any follow up questions for “us”. I’m hoping to get this working with my SmartThings hub. I was really hoping there might be a way to reclaim control of the bridges, but I seem stuck unless I tear mine apart… I can’t seem to find an elegant way to disassemble the sucker… and there’s no guarantee that will lead to success.