In this article series, I will show you how to implement OTA updates via BLE for the ESP32 without external libraries. We will use the NimBLE stack provided by the ESP-IDF for implementing the OTA code. The code is not compatible with the Arduino framework, but the concept is transferable. You can find the complete source code on github and use it as a template for your own projects.

In the first part of this tutorial series we set up the ESP32’s partition table for the OTA process and we reviewed the basics of BLE. In the last part we took a look at the OTA implementation on the ESP32 side. In this part we will implement the PC side of the OTA process using Python and the library Bleak.

Python Code

The client-side is implemented in Python using the platform-agnostic BLE library Bleak. Bleak uses Python’s asyncio library for writing asynchronous code, you should take a look at the docs to get familiar if you don’t know it. Don’t worry you don’t need a deep understanding of asynchronous programming to follow this tutorial.

The script is quite self-explanatory: First, it scans for BLE devices and searches for a device name esp32:

esp32 = None
devices = await
for device in devices:
   if == "esp32":
      esp32 = device

Once it finds the ESP32, it calculates the packet size and writes it to the OTA data characteristic:

async with BleakClient(esp32) as client:

   # compute the packet size
   packet_size = (client.mtu_size - 3)

   # write the packet size to OTA Data
   print(f"Sending packet size: {packet_size}.")
   await client.write_gatt_char(
      packet_size.to_bytes(2, 'little'),

Before we can send the packets, we will subscribe to the OTA Control characteristic. The method expects the UUID and a handler. The handler executes every time we receive a notification from the ESP32:

async def _ota_notification_handler(sender: int, data: bytearray):
         print("ESP32: OTA request acknowledged.")
         await queue.put("ack")
         print("ESP32: OTA request NOT acknowledged.")
         await queue.put("nak")
         await client.stop_notify(OTA_CONTROL_UUID)
         print("ESP32: OTA done acknowledged.")
         await queue.put("ack")
         await client.stop_notify(OTA_CONTROL_UUID)
         print("ESP32: OTA done NOT acknowledged.")
         await queue.put("nak")
         await client.stop_notify(OTA_CONTROL_UUID)
         print(f"Notification received: sender: {sender}, data: {data}")

await client.start_notify(

To synchronize these events, we use a queue: Every time we receive an acknowledgment put an ack / nak in it. In the main method, we can await this later and react to it.

After setting up the notifications, we split the firmware into packets and start the OTA process by writing to the OTA Control characteristic. Once the ESP32 acknowledged the OTA, the handler puts an ack to the queue, and the script sends the packets:

# split the firmware into packets
firmware = []
with open("esp32_ble_ota.bin", "rb") as file:
   while chunk :=

# write the request OP code to OTA Control
print("Sending OTA request.")
await client.write_gatt_char(

# wait for the response
await asyncio.sleep(1)
if await queue.get() == "ack":
   # sequentially write all packets to OTA data
   for i, pkg in enumerate(firmware):
         print(f"Sending packet {i+1}/{len(firmware)}.")
         await client.write_gatt_char(

If the packet transfer is over, we write to the OTA Control characteristic to indicate that the OTA is over. The ESP32 then sends a final notification before rebooting and awaking as a new self.


In this tutorial series, we implemented OTA updates via BLE using the NimBLE stack provided by Espressif. We wrote client-side code with Python and the library Bleak. Using this setup, I was able to transfer firmware (500kb) from my Macbook to the ESP32 via BLE in around 1min:30s. Feel free to use the provided code as a template project and let me know what you build with it!


Full Source Code